HomeDocumentationiOS Under The Surface
iOS Under The Surface
14

Memory: Jetsam, Compression, and No Second Chances

iOS Memory Management and Jetsam for Flutter Developers

March 30, 2026

iOS has no swap. This single fact shapes everything about memory management on the platform.

On Android, when physical memory fills up, the kernel compresses pages into zram — a region of RAM that stores compressed data (Android series, Post 5). A 4KB page might compress to 1KB, effectively tripling available memory. Pages that haven't been accessed recently are compressed and stored, then decompressed on demand when the process touches them again. This buys time. It lets more processes stay alive, longer.

iOS compresses memory too, but it has no swap device — not even a compressed one. When compressed memory plus active memory fills physical RAM, something has to die. There's no buffer, no overflow, no deferred reclamation. The system must free physical pages immediately, and the only way to free pages held by a running process is to kill that process.

The mechanism that decides what dies is called Jetsam.

Why no swap?

The decision is deliberate, not a limitation. iOS devices have fast NAND flash storage that could theoretically be used for swap. Apple chose not to, for several reasons:

Flash wear. NAND flash has a limited number of write cycles per cell. Swap generates enormous write traffic — pages are constantly written out and read back. On a device that's expected to last 5+ years, swap-induced flash wear would degrade storage reliability.

Latency. Even fast flash is orders of magnitude slower than RAM. A page fault that hits swap takes milliseconds; a page fault that hits RAM takes nanoseconds. On a device where fluid 60/120fps animation is expected, swap-induced latency spikes are unacceptable. A single swap-in during a scroll or animation could drop multiple frames.

Predictability. With swap, memory usage is elastic but unpredictable. A process can allocate far more memory than physical RAM and continue running (slowly) as pages are swapped. Without swap, memory limits are hard — cross them and the process is killed. This makes performance more predictable: an app either has enough memory or it doesn't. There's no degraded-but-running middle ground.

The consequence: every byte of data that a process uses must fit in physical RAM. Not virtual RAM. Not "RAM plus some overflow to disk." Actual, physical, on-chip RAM. When the total memory demand from all processes exceeds what the chip has, Jetsam starts killing.

Jetsam: the memory enforcer

Jetsam (named after the maritime term for cargo thrown overboard to lighten a ship) is the iOS subsystem that terminates processes to free memory. It's part of the kernel — a thread that monitors memory pressure and takes action when thresholds are crossed.

Jetsam operates on a priority system similar in concept to Android's oom_adj (Android series, Post 5):

| Priority band | Description | Example | |---|---|---| | Critical | System-essential processes | kernel, launchd, backboardd | | Foreground | App the user is actively using | Your Flutter app (when in foreground) | | Foreground (inactive) | Visible but not interactive | App behind a system alert | | Background | Suspended background apps | Your app after the user switched away | | Idle | System daemons that aren't currently needed | Background indexing services |

When memory pressure reaches a threshold, Jetsam kills processes from the lowest priority band first. Within a band, it typically kills the largest process first (the one consuming the most physical pages), though the exact algorithm varies by iOS version.

The priority order means:

  1. Idle daemons die first. Background system services are killed. The user doesn't notice.
  2. Suspended apps die next. Starting from the least recently used, suspended apps are terminated. The user might notice if they try to switch back to one and it restarts.
  3. Background apps still executing. Apps in their background execution window (the 30 seconds from Post 3) are killed if the situation is severe enough.
  4. The foreground app. This is the nuclear option. If memory pressure is so extreme that killing all background processes isn't enough, Jetsam kills the foreground app. The user sees a crash — the app disappears and the home screen appears. This is rare and represents a critical failure, usually caused by the foreground app itself consuming too much memory.

You can see Jetsam events in the device's crash logs. Look for files named JetsamEvent-*.ips in Settings → Privacy → Analytics & Improvements → Analytics Data. These files show every process that was running, its memory footprint, and which one was killed.

Memory limits per process

Unlike Android, which has a per-app Java heap limit (typically 256-512MB) but allows native memory to grow larger, iOS imposes a practical per-process memory limit that varies by device:

| Device | RAM | Approximate per-app limit | |--------|-----|--------------------------| | iPhone SE (3rd gen) | 4GB | ~1.4GB | | iPhone 14 | 6GB | ~2.8GB | | iPhone 15 Pro | 8GB | ~3.5GB | | iPad Pro M4 | 16GB | ~5GB+ |

These aren't published limits — Apple doesn't document them. They're empirically observed thresholds where Jetsam kills the process. The limits depend on total device RAM, the memory usage of other processes, and the iOS version. But the pattern holds: an app can typically use about 50-70% of the device's physical RAM before Jetsam intervenes.

For Flutter apps, the Dart heap, the engine's native allocations, Impeller's GPU resources, image buffers, and plugin memory all count toward this limit. A Flutter app that decodes ten full-resolution photos (each 8-12MB when decoded to RGBA) can easily consume 80-120MB of memory just for images. Add the Dart heap, the engine overhead, and system framework memory, and you're at 200-300MB quickly.

On a 4GB iPhone SE, that 200-300MB is a significant fraction of the per-app budget. On an 8GB iPhone 15 Pro, it's comfortable. This is why memory optimisation is more critical on lower-end iOS devices — the Jetsam threshold is closer, and the margin for error is smaller.

Memory compression on iOS

iOS does use memory compression — just not swap. When memory pressure increases but before Jetsam starts killing, the system's VM compressor compresses pages in place.

The compressor takes infrequently accessed pages, compresses their contents, and stores the compressed data in a smaller region of physical memory. The original page frames are freed. If the process accesses the compressed data, the system decompresses it transparently.

This is functionally similar to Android's zram, but with a critical difference: on Android, compressed pages can accumulate indefinitely in the zram device (up to the configured zram size). On iOS, compressed pages compete for the same physical RAM as everything else. If compressed pages plus active pages fill physical RAM, Jetsam kills processes to free both.

Compression buys time — it lets more data fit in physical RAM — but it doesn't fundamentally change the constraint. Physical RAM is the hard limit.

You can observe compression in action:

bash
# On a macOS device (same kernel, similar tools)
vm_stat

Or in Xcode's Memory Report, which shows your app's memory footprint broken down into:

  • Dirty memory: Pages your app has written to. These can't be evicted without data loss. This is the expensive part.
  • Compressed memory: Dirty pages that the system has compressed. Still counts against your footprint but uses less physical RAM.
  • Clean memory: Pages backed by files on disk (code, mapped resources). Can be evicted and re-loaded from disk.

The number that Jetsam cares about is your process's physical footprint — dirty + compressed + wired (kernel-locked) pages. Clean pages aren't counted because they can be reclaimed for free by re-reading from disk.

Memory warnings: your last chance

Before Jetsam kills your app, the system sends memory warnings. These are your opportunity to reduce memory usage and avoid termination.

On the iOS side, UIApplication sends didReceiveMemoryWarning to the app delegate and posts a UIApplication.didReceiveMemoryWarningNotification notification. The Flutter engine receives this and forwards it to the Dart side.

In Dart, you can listen for memory pressure through SystemChannels.system:

dart
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didHaveMemoryPressure() {
    // The system is running low on memory.
    // Release caches, drop unused images, trim data structures.
    imageCache.clear();
    imageCache.clearLiveImages();
    _releaseCachedData();
  }
}

The Flutter engine itself responds to memory warnings by:

  • Clearing the font cache
  • Releasing GPU resources that can be recreated
  • Trimming internal caches

But the engine can't release your Dart objects — only your code knows which data can be safely discarded. If you're holding large image buffers, cached API responses, or pre-computed data structures, the memory warning is your signal to drop them.

The warning isn't a guarantee of survival. If your app is in the background and memory pressure is severe, Jetsam may kill you even after sending the warning. The warning is a "do what you can in the time you have" signal, not a "you're safe if you respond" guarantee.

Multiple warning levels

iOS sends memory warnings at different severity levels, though the Dart API doesn't expose the granularity directly. The system may send multiple warnings of increasing severity before killing:

  1. Low pressure. The system is starting to feel constrained. Background apps might be compressed. Good time to release optional caches.
  2. Medium pressure. The system is actively reclaiming memory. Suspended apps are being killed. If you're in the foreground, trim everything non-essential.
  3. Critical pressure. Jetsam is about to kill. This may be the last warning before your process is terminated.

In practice, the Dart didHaveMemoryPressure callback fires for all levels. You should respond aggressively every time — there's no way to know which warning is the last.

What fills memory in a Flutter app on iOS

Understanding where memory goes is the first step to managing it:

Decoded images. The single largest memory consumer in most Flutter apps. A 12MP photo from the camera (4032×3024 pixels) takes ~49MB when decoded to RGBA. Even a standard 1080p image is ~8MB. Images in a ListView that haven't been evicted from the cache accumulate quickly.

The ImageCache in Flutter has default limits: 1000 images or 100MB. On a 4GB device where Jetsam gives you ~1.4GB total, 100MB of image cache alone is 7% of your budget. Use cacheWidth and cacheHeight to decode images at display resolution instead of full resolution:

dart
Image.asset(
  'assets/photo.jpg',
  cacheWidth: 540,  // Half of 1080p width
  cacheHeight: 960,
)

This decodes the image at 540×960 instead of full resolution, reducing memory from ~8MB to ~2MB per image.

The Dart heap. Your widget trees, state objects, model objects, cached data. The Dart GC manages this, but between collection cycles, dead objects still occupy pages. Rapid allocation (building complex widget trees during scrolling) creates transient memory peaks.

Impeller's GPU allocations. Textures, render targets, shader buffers. These are allocated through Metal and backed by physical memory (iOS doesn't have a separate GPU memory pool — the CPU and GPU share the same unified memory on Apple Silicon). Large textures or complex render passes consume memory that's invisible to the Dart heap but very visible to Jetsam.

System framework overhead. UIKit, Foundation, CoreGraphics — the framework code mapped from the dyld shared cache (Post 2). These pages are clean (backed by the shared cache on disk) and don't count heavily against your footprint. But framework data structures (auto-layout state, layer trees, notification observers) do allocate dirty memory.

Plugin allocations. Native code in plugins — camera buffers, ML model weights, database page caches — allocates memory outside the Dart heap. The camera plugin maintaining a live preview, for example, holds multiple frame buffers in memory simultaneously.

Debugging memory on iOS

Xcode Memory Report. With the app running via Xcode, the Memory Report shows real-time footprint, broken down by category. The "Memory" gauge in the debug navigator turns yellow (warning) and red (critical) as pressure increases.

Instruments — Allocations. The Allocations instrument shows every allocation your process makes, including call stacks. You can see which Dart operations trigger native allocations, which images are decoded, and where memory peaks occur.

Instruments — VM Tracker. Shows virtual memory regions: dirty, clean, compressed, and swapped-compressed. This gives you the same view Jetsam has — the physical footprint that determines your survival.

Instruments — Leaks. Detects reference cycles in Objective-C/Swift code (not Dart, since the Dart GC handles cycles). Useful for finding leaks in plugin code.

`footprint` command. On a connected device via Xcode:

bash
# View memory footprint
footprint -a --proc=YourApp

This shows the per-process memory footprint as Jetsam sees it.

Jetsam logs. After a Jetsam termination, a log file is generated:

javascript
{"memoryStatus": {
  "memoryPressure": true,
  "compressorSize": 548124,
  "pageSize": 16384,
  "uncompressed": 1256789
},
"largestProcess": "YourApp",
"processesTerminated": [
  {"name": "YourApp", "rpages": 89234, "reason": "per-process-limit"}
]}

The rpages count times the page size (16KB on modern devices) gives you the memory footprint at the time of death. 89234 × 16KB ≈ 1.39GB — the process hit its per-process limit.

Surviving Jetsam: practical strategies

Respect `didHaveMemoryPressure`. Clear image caches, drop non-essential data, release any large buffers. Respond immediately — you may not get a second warning.

Size images for the screen, not the file. Use cacheWidth/cacheHeight. A user on a 390×844 logical pixel iPhone doesn't need a 4032×3024 decoded image.

Evict off-screen images. In long lists, images scrolled off-screen should be evictable from the cache. The default ImageCache handles this for Image.network and Image.asset, but custom image loading (from files, from camera) may need manual cache management.

Measure physical footprint, not Dart heap. The Dart DevTools memory tab shows the Dart heap, but Jetsam cares about the total physical footprint — including native allocations, GPU resources, and framework overhead. Use Xcode's Memory Report or Instruments for the true picture.

Test on low-memory devices. A 4GB iPhone SE has less than half the per-app budget of an 8GB iPhone 15 Pro. If your app survives on the SE, it survives everywhere.

Profile memory during stress scenarios. Open a screen with a large image grid. Scroll aggressively for 30 seconds. Background the app, open several other apps, return. This simulates real-world memory pressure. If your app restarts instead of resuming, you're over budget.

The memory constraint on iOS is harder than on Android — no zram escape valve, no background services to defer work. But it's also more predictable. Your app either fits in memory or it doesn't. There's no slow degradation, no compressed-but-alive limbo. Understanding the budget and measuring against it is the entire strategy.

The next post covers Grand Central Dispatch and the thread model — how iOS manages concurrent work and how it interacts with Flutter's own threading architecture.

This is Post 4 of the iOS Under the Surface series. Previous: The App Lifecycle: Five States and Hard Rules. Next: Grand Central Dispatch and the Thread Model.

Related Topics

ios jetsam flutterios memory managementios memory warning flutterios no swapios app killed memoryflutter ios memory optimizationios memory limit flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.