Previously, we explored how memory is measured and what tools are available for inspecting usage in iOS apps. Now, let’s shift our focus to reducing memory consumption using a set of practical techniques and development best practices. But first let’s discuss different approaches to treat this problem.
Initial Source of a Problem
Every spike in memory has a cause, and memory optimization is no exception. Sometimes it’s a 3rd-party library — Lottie is a classic example, especially if you export animations from After Effects and it ends up loading every frame as a raw bitmap. Other times, it’s your own code — maybe a local class that creates and deletes heavy objects without much control.
There are usually two ways teams deal with memory growth:
– Reactive approach. You ship first, and worry only if memory usage starts ballooning. This is common for small or indie teams with tight deadlines, simple flows, or low data volume. If it scrolls smooth, no one’s profiling UIScrollView. The downside? If memory becomes a problem later, untangling the logic might be painful. I’ve been there — one lazily loaded animation spiked memory to 1GB just because it can.
– Proactive (defensive) coding. This is about thinking ahead — using defer to clean up resources, preferring lighter assets, considering NSCache or smarter containers, swapping animation frameworks, or hardcoding assets if needed. There’s no silver bullet, but the earlier you start managing memory consciously, the less tech debt you’ll face later. And honestly, nothing feels better than seeing your app stay under 50MB of memory on a cold start.
Let’s discuss how to be informed about it and some main places of optimizations.
How to Respond to Low-Memory Warning?
When your app starts getting close to the system’s memory limit, iOS gives you a heads-up — that’s when the memory gauge hits yellow in Xcode. You’ll get a memory warning, and it can come in a few different ways:
applicationDidReceiveMemoryWarning(_:) gets called on your app delegate, but it’s slowly getting deprecated.
Active UIViewControllers receive didReceiveMemoryWarning()
NotificationCenter posts a didReceiveMemoryWarningNotification and in future UIApplication.DidReceiveMemoryWarningMessage . It’s in Beta iOS 26 so no extra info. And whole Notifications Concurrency-Safe logic is in REVIEW at the moment of article posting.
Background queues can get a DISPATCH_SOURCE_TYPE_MEMORYPRESSURE event
iOS sends low-memory warnings on a best-effort basis, so your app needs to react fast. If system memory pressure keeps rising and apps aren’t freeing memory quickly enough, iOS won’t wait — it’ll start force-quitting apps to reclaim space. When that happens, your app gets terminated and the reason gets logged as a jetsam event. You can find the details in the system logs and use them to figure out what went wrong. Jetsam system description goes beyond current article but you can read about it on Apple Documentation.
Now we know when memory is approaching its limit. Let’s check the main spots of optimizations.
Optimize Image Assets
Large images can quietly eat up a huge chunk of memory — especially those with high color depth. The fix? Don’t load more than you plan to show. If an image is only ever going to be displayed at 200×200 points, bundling a 4K PNG makes zero sense.
For app-bundled assets, size them for display — not for design specs. And for dynamic images (downloaded from the network, pulled from the photo library, or generated on the fly), always scale them down to what you actually need. You’ll also want to strip metadata and reduce color depth where possible — most apps don’t need full 16-bit channel precision just to show a thumbnail.
What are the tips:
Use Image I/O to handle thumb creation and keep memory in check
Load images lazily — only when needed — to avoid wasting memory upfront. AsyncImage or Kingfisher are the options.
Use asset catalogs — they auto-generate optimized image variants for each device scale, so you don’t have to
Reduce the size of Core Data transactions
Core Data keeps changes in memory until you save the NSManagedObjectContext. That means the longer you delay saving, the more memory it holds onto. On the flip side, saving too often hits the disk more — which can hurt performance and wear out the hard drive over time. The trick is finding a sweet spot: save often enough to keep memory under control, but not so often that you’re constantly writing to storage. Balance is key.
Purgeable Data
A hidden gem of Swift classes. It lets the system wipe the data automatically when memory gets tight, without waiting for your app to react. Just wrap your content with beginContentAccess() when needed, and the rest is handled by the kernel. It’s a smart way to stay ahead of memory warnings without writing extra cleanup logic.
Example of NSPurgeableData flow
More info
Prefer Lazy SwiftUI Containers and Lazy Variables
When working in SwiftUI, using LazyVStack and LazyHStack ensures that view content is only loaded when it enters the visible viewport. This dramatically reduces memory usage for lists or grids with many items.
For data and object properties, using lazy var or manually deferring initialization allows you to avoid allocating memory upfront. This is especially helpful when a property is rarely accessed—like a preview image, a debug-only tool, or an optional data cache.
Lazy UILabel
Lazy loading aligns resource allocation with actual user interaction, which results in lower memory footprints and smoother transitions, especially in resource-constrained environments.
Prefer Value Types for Simpler Memory Management
Value types like struct and enum in Swift are copied on assignment and typically allocated on the stack or inlined by the compiler. They are inherently simpler to reason about and don’t rely on reference counting like classes do.
This simplicity leads to more predictable memory behavior and eliminates issues like retain cycles. For example, using a struct instead of a class for a model object ensures that data is self-contained and that memory is freed as soon as it goes out of scope.
For large apps, favoring value types in your data models, view models, and configuration objects can significantly reduce memory overhead and make your code easier to debug and test.
Use Caching Wisely
Caching can reduce recomputation and improve performance—but it can also grow uncontrollably if not handled properly. NSCache is a great tool for managing memory-sensitive caches because it automatically purges entries under pressure, similar to how iOS handles system memory.
For simpler cases, even a static let or a shared singleton property can act as a lightweight memoization layer. This is particularly useful for objects that are expensive to create but reusable, like formatted strings, computed layout dimensions, or loaded assets.
Always keep in mind the cache’s lifecycle. Monitor and tune the cache size, and avoid using strong references that might keep large data around longer than necessary. Consider implementing your own eviction strategy based on frequency or time.
In example, here is an actor for storing HealthKit Heart Rate values for a specific time. Calls to HealthKit API is expensive and caching fits great here.
NSCache key must be a reference type so as the value
Remove references to unused objects
An app can accumulate memory it still references but no longer actively uses. This kind of unused memory inflates overall usage, offers no real benefit to functionality, and won’t be flagged by tools like the Leaks instrument — since it’s not technically leaked, just forgotten.
You don’t need to keep images, videos, SceneKit scenes, or other view-related objects in memory when they’re not actively in use — especially during backgrounding or inactive UI states. In example, sport app with live event during match. While user is moved to Settings or any other part where is no linking to current event, you can stop the timers, hold the requests to fetch live data, etc.
Eliminate Memory Leaks
Memory leaks cause objects to stay in memory indefinitely, even when they’re no longer needed. These leaks can be silent and cumulative, eventually leading to crashes due to memory pressure.
Use the Memory Graph Debugger in Xcode to identify retain cycles and trace why an object remains in memory. Instruments’ Leaks template is another powerful tool—it scans for leaked objects during runtime and gives you precise information about their origins.
Focus on breaking strong reference cycles, especially in closures, delegate patterns, or between views and view controllers. Adopt [weak self] or [unowned self] where appropriate, and avoid unnecessary global or static references.
Regularly profiling for leaks during development and QA ensures your app remains stable and memory-efficient across sessions.
Table Turns
After all optimizations, profiling and refactoring you still ended up with memory warning and application termination… The only solution in that case might be obvious: cut the features, change the logic or remove it totally. Before that I would like to reveal a few not obvious and not promoted widely options.
Did you know that you can request extension of virtual addressing space and extend memory limit?! Let me introduce you couple of Bundle Entitlements:
com.apple.developer.kernel.extended-virtual-addressing
Apple Docs are straightforward:
Use this entitlement if your app has specific needs that require a larger addressable space. For example, games that memory map assets to stream to the GPU may benefit from a larger address space. Enable this entitlement with the “Extended Virtual Addressing” capability in the Xcode project editor.
This is how it looks in list:
com.apple.developer.kernel.increased-memory-limit
Also from Docs:
Add this entitlement to your app to inform the system that some of your app’s core features may perform better by exceeding the default app memory limit on supported devices. If you use this entitlement, make sure your app still behaves correctly if additional memory isn’t available. An increased memory limit is only available on some device models. Call the os_proc_available_memory function to determine the amount of memory available. Higher memory use can affect system performance.
and he’s sibling
com.apple.developer.kernel.increased-debugging-memory-limit
Does the same as the key before but for debugging:
Enables apps to access additional memory during development and internal testing to enable tools-oriented workflows in high-watermark situations.
All of these methods doe not guarantee that if you set them your app will push others from execution list and will work all the time. Of course it will just mark somewhere that app might go beyond the limits. This work for both private and public distribution for now. No request for entitlements is required.
Good luck and happy coding!