Cutting a React Native app from 1.2GB to 350MB
My first mobile app started at 700MB, climbed past 1.2GB, and got OOM-killed on real devices. The full teardown of how it reached a stable 350MB — and why the real leak was the shape of the app, not a setting.
20 May 2026 · 6 min read
January 2026 was the first time I ever touched a mobile app. Eight years of web behind me, mobile basically zero. So when I opened the project in the simulator and saw 700MB of memory at startup, I genuinely didn’t know whether that was bad.
I looked it up. People said 400–500MB is roughly normal for a React Native app. Fine. Then I started clicking between screens and watched the number climb — +50MB here, +100 there, another +300 — and it never came back down. Fifteen screens in I was at 1.2GB, and once I saw 1.6GB. The simulator doesn’t care; it’ll happily eat the whole Mac. A real device does care: iOS killed the app on my own phone, and Xcode showed me the memory-pressure termination. To find out how bad it really was, I dug out an old 2018 Android with 3GB of RAM total.
This is the teardown of how that app got to a stable ~350MB. The client specifics are left out — these are patterns, not a case study.
The obvious fixes come first — and aren’t enough
I went after the easy stuff first, because it was easy and it was real. A provider tree nested about twenty deep, half of it for things the app no longer used. Subscriptions getting torn down and recreated on every render. Both worth fixing on their own, so I fixed them. Startup dropped to around 600MB.
Better, but the leak was still there, climbing on every screen. And I sat on that for a week, then two, getting nowhere — because I just didn’t know mobile development well enough yet to see what I was looking at. That’s the honest part nobody writes down: sometimes you’re not stuck because the problem is hard, you’re stuck because you haven’t earned the context to recognise it.
The leak was the shape of the app
It only clicked maybe two months later, once I knew enough React Native to start redesigning and actually looked at the shape of the thing instead of the individual screens. Three navigation stacks nested inside each other — a screen inside a screen inside a screen — and every one of them stayed mounted in memory the moment you opened it. Nothing was leaking in the “forgot to free a buffer” sense. The architecture was holding the entire history of where you’d been.
I threw that whole pattern out and rebuilt the navigation flat. The leak just stopped. 350MB typical, 400MB worst case, on anything — including the 2018 Android.
The leak was never a setting I could flip. It was the structure.
The discipline that keeps it flat
Ripping out the nested stacks stopped the bleeding. Keeping memory flat after that came down to a handful of unglamorous habits — the boring core of mobile performance. None of these is clever. That’s the point.
Scope state to the screen that needs it
The biggest ongoing wins came from not holding data globally “just in case.” It’s easy to lift everything into a global store because it’s convenient — and then nothing is ever released, because something, somewhere, might still reference it. So data lives as close to the screen that uses it as possible, and leaves when the screen does. Global state is reserved for things that are genuinely global — session, theme, feature flags — not “the last twelve screens of API responses I might reuse.”
Key caches so they can actually be released
A cache keyed in a way that never invalidates isn’t a cache — it’s a leak with good intentions. Keyed per user and per session, a cache can be dropped cleanly when the user or session changes, instead of quietly accumulating every entity the app has ever seen.
Stop context providers re-creating objects every render
A Context.Provider whose value is a fresh object on every render forces every consumer to re-render — and keeps allocating. Memoising the provider value, and the callbacks inside it, so their identity is stable removes a whole class of churn. It doesn’t look like a leak in the code; it shows up clearly in the memory graph.
Don’t keep every list row alive
Long scrollable screens are where memory goes to die. A list rendered with a plain map keeps every row mounted at once — and every image and subscription each row holds. Virtualised lists that window their content (render what’s visible, recycle the rest), plus right-sizing images to their display size instead of their source size, keep the working set bounded no matter how long the list gets.
The tool that found what I’d gone blind to
After the structural fixes, the long tail was the stuff you stop seeing in code you’ve read fifty times: closures that captured more than they needed, effects that subscribed without ever cleaning up.
What surfaced them was a deliberately brutal review pass. I ran the diffs through an AI code review with a sharp prompt — play a senior engineer; this app ships to 100k users tomorrow; hunt every leak and every missing cleanup, and assume the worst. It flagged the retained closures and the missing removeEventListener / unsubscribe calls I’d gone blind to. AI is genuinely good at this tedious leak-hunting pass — but only if you point it like an adversary. Ask it nicely and it tells you the code looks great.
The real lesson
The leak was never a setting. It was the shape of the app, and I only got to fix it because I had enough ownership to rip that structure out and redo it. In a bigger team, with more sign-offs between me and the navigation layer, I’m honestly not sure I could have.
Maybe that’s the real lesson: some performance problems aren’t technical at all. They’re about who is allowed to change the architecture.