Get a demo
Symptom → evidence

iOS app memory grows until it crashes

Answering: “ios app memory grows until it crashes”

Evidence device memory limit jetsam kill RSS each action allocates and never frees
A resident-memory curve that steps up with each action and never falls, crossing the device memory limit where iOS terminates the app.

Memory that climbs and never falls back is retained allocations the app never releases: a retain cycle, an unbounded cache, or an observer or timer that outlives the screen that created it. To find which one, read the memory curve across the whole flow, because a single snapshot cannot separate a leak from normal churn.

Fine at launch, dead after a few minutes

The app launches clean. You browse a feed, open a detail screen, back out, and repeat. Nothing looks wrong, but the app gets slower, and after a few minutes it vanishes with no crash dialog and no stack trace in Xcode. Check the device logs later and there is a JetsamEvent, not an exception. You already opened the memory graph debugger once, saw a number that looked high, and could not tell whether it was a real leak or a busy moment.

Growth without a drop is retained allocations

Memory that only climbs has one root shape: allocations that stay reachable after you are done with them, so ARC never frees them. Three mechanisms cover nearly all of them in a SwiftUI app.

Retain cycles. These need reference types, so the culprit is a class the screen owns, a view model or ObservableObject, not the SwiftUI value-type view. Two objects hold strong references to each other, or a closure captures self strongly and is itself stored on self: a Combine subscription kept in a cancellable that references self, a delegate declared strong instead of weak, a Timer created with a target the run loop keeps alive. The reference count never reaches zero, so that object graph leaks every time the screen appears.

An unbounded cache. Decoded images or fetched data pushed into a plain Dictionary or Array with no eviction. NSCache drops entries under memory pressure; a dictionary does not. Full-resolution images are the worst case, because the decoded backing store is far larger than the file on disk, and each one you keep is megabytes that never come back.

Observers and timers never torn down. A repeating Timer or CADisplayLink that keeps firing after the view is gone, a NotificationCenter block observer whose token you never removed, a KVO registration left in place. These retain their target and keep doing work, appending to state on every tick.

When the footprint crosses the per-app limit for that device, iOS jetsams the process. That ceiling depends on the device’s RAM, so the same build can survive on a high-memory phone and get killed on a lower-memory one. React Native shows the same shapes: the retain cycle becomes a listener or subscription you never remove in a useEffect cleanup, and the unbounded cache becomes an ever-growing array in a store or an image list with no windowing.

The curve shape names the leak

Each cause draws a different shape, and the shape only shows up when the memory trace covers the whole session. A number you glance at in a gauge hides it.

A retain cycle draws a staircase. Memory steps up by a fixed amount each time you push the screen and does not drop when you pop it. The step size is one screen’s retained object graph. If every back-out leaves a higher floor, the leak is per-screen retention.

An unbounded cache draws a ramp that plateaus. Memory rises smoothly while items load, proportional to how many, then holds flat at the ceiling once the load finishes instead of falling back. The photoleak demo app makes this exact shape on purpose. Its Stress Test (Load 500 Photos) button fires 500 concurrent thumbnail downloads into a plain dictionary cache with no eviction, so RSS ramps as the decoded images pile up and then holds flat at the top once the run finishes. The FPS trace dips while the images decode, and the device logs print the cache size climbing past each threshold with no eviction firing. The tell is that RSS ramps and pins instead of rising and settling: retained decoded bytes held in memory, not churn.

An observer or timer draws a slope tied to time, not navigation. Memory creeps up even when you are not touching the screen, and the logs show the timer callback still firing after you left the view that started it.

On a simulator or on a device on your desk, Instruments (Allocations and Leaks) and the memory graph debugger draw these same shapes, and they go past a raw RSS line by handing you allocation backtraces and the retain-cycle graph. You can save that session as a .trace file, scrub it, and drop it into a ticket. For a leak you can already trigger by hand, that is the honest first move. What it costs is a developer sitting down to attach Instruments and drive the flow themselves, one machine and one run at a time, with nothing capturing the curve on its own on every build. So a leak that surfaces on one navigation in ten stays hidden behind however many rounds someone has the patience to repeat.

Reproduce it on a cloud device

Revyl boots the model and iOS version your users run, installs the build, drives the leaking flow, and records the whole run: video, a step-by-step timeline with a grounding screenshot on every action, the memory, CPU, and FPS performance chart, a network waterfall, and device logs. Memory warnings and any app termination are marked on the chart as dashed lines, so the moment the OS pushes back lines up with the curve. Drive the flow once yourself in a live session to watch it, or wire it as the load-500-photos agent test that walks the exact same navigation on every pass. That repetition is the part a local session leaves to you. Let the test loop unattended and an intermittent leak that only trips on some runs finally shows itself, and every pass leaves a record you can replay, line up against a clean run, or hand to a teammate. The simulator draws on the host machine’s memory and macOS can page to disk, so the run may never cross the jetsam ceiling a 3GB phone hits, and its absolute curve and timing will not match that phone. What holds is the relative trend: memory that climbs each pass and never falls back is retention, and the trace names the cause. You are diagnosing the leak and its shape, not reproducing the moment a smaller device dies.

revyl auth login
revyl build --platform ios
revyl test run "load-500-photos" --build-id <build-id>   # <build-id> printed by revyl build

Technical sources

Common questions

Why does my iOS app get killed with no crash log?
iOS terminates apps by jetsam when their memory footprint crosses the per-app limit for that device, and a jetsam is not an exception, so you get a JetsamEvent report instead of a normal crash log. Memory that only grows until this happens is retained allocations the app never releases, like a retain cycle, an unbounded cache, or a timer that outlives its screen.
How do I tell a memory leak from normal memory use?
A single snapshot cannot tell them apart. You need the memory curve across the whole flow. A leak shows a staircase that steps up on each navigation and never drops on the way back, or a smooth ramp that plateaus at a ceiling and holds after the work finishes. Normal churn rises and falls.
Why can't I reproduce the memory crash on my own device?
The jetsam kill is gated by the device's RAM, so a machine with more memory, including a cloud iOS simulator, may never cross the ceiling that kills a lower-memory phone. The leak itself is not device specific. It draws the same climbing, never-settling shape wherever it runs, even if the exact curve and timing shift with the host, so you diagnose it from that shape in a captured, replayable run instead of from the kill.

Reproduce it on a cloud device

Choose a supported model and OS pair, run the flow, and read the full record of what happened.

Start free Read the docs