Skip to content
EK

Search

Medical Avenue · Founding Engineer (Contract) · 2026

Taking over a React Native app that was OOM-killing itself: 1.2GB → 350MB

  • 1.2GB → ~350MB stable
  • OOM kills on devices → zero
  • Verified on 2018 Android, 3GB RAM
  • Solo takeover from agency team

Inherited a React Native patient app from a departed agency team, traced a memory profile that climbed past 1.2GB and got the app killed on real devices to the navigation architecture itself, and rebuilt it to a stable ~350MB — verified on a 2018 Android with 3GB of RAM.

React Native · Expo · TypeScript · SWR · Zustand · MMKV

Context

Medical Avenue runs a medical-travel platform: a React Native patient app, a coordinator dashboard, and a Node/Prisma/PostgreSQL backend. The app had been built by an agency team that was gone by the time I arrived. In January 2026 I took the codebase over as the sole engineer — and it was my first mobile project after eight years of web, which matters to this story.

Problem

The app started at 700MB of memory and climbed from there — +50MB here, +100MB there on every screen transition, and the number never came back down. Fifteen screens in, it sat at 1.2GB; the worst observed reading was 1.6GB. The simulator tolerates that. Real devices don’t: iOS killed the app with a memory-pressure termination on an ordinary phone, which for a patient-facing product means crashes precisely during the multi-step flows — intake forms, document uploads — where users have the most to lose.

Constraints

  • Live users on the app; releases had to keep shipping while the investigation ran.
  • Sole engineer — no mobile veteran to hand the profiler to.
  • The app also carried dead weight from its previous life as an e-commerce product, so any structural fix had to double as cleanup, not add a parallel structure.

My role

All of it — diagnosis, the architectural redesign, and the stabilization work around it, delivered incrementally over roughly four months.

Approach

The obvious fixes came first: a provider tree nested about twenty levels deep (half of it for features the app no longer had), and subscriptions being torn down and recreated on every render. Real problems, worth fixing — startup dropped to around 600MB. But the climb-per-screen remained, and it stayed unexplained for weeks until I’d learned enough React Native to read the shape of the app instead of individual screens.

The shape was the leak. Three navigation stacks nested inside one another meant every screen you visited stayed mounted — the architecture was holding the entire history of where you’d been. Nothing “leaked” in the classic sense; retention was the design. I flattened the navigation, deleted the legacy e-commerce route groups outright (cart, orders, wallet), and the climb stopped.

Keeping it flat was a set of deliberately boring disciplines: screen-scoped data moved out of global providers and into SWR, with cache keys scoped per user so a session change can actually release its data; provider values memoized so consumers stop re-rendering and re-allocating; images rendered through expo-image at display size instead of source size; client storage consolidated on MMKV; and an aggressive-retry failure mode fixed so a flaky network couldn’t spawn a storm of concurrent refetches.

Key decisions

  • Fix the structure, not the symptoms. Two months of tactical fixes bought ~100MB; the navigation redesign bought the rest. The willingness to rip out the routing layer of a live app — carefully, incrementally — was the actual unlock.
  • Delete rather than quarantine. The e-commerce era screens weren’t flagged off or archived; they were removed, along with their stores and locale keys. Less code, less resident memory, less to reason about.
  • Scope caches so they can die. A cache that nothing can invalidate is a leak with good intentions. Per-user, per-session keys made “release everything on logout” a one-liner instead of a hunt.
  • Verify on the worst realistic device. The acceptance bar was a 2018 Android with 3GB of RAM total, not the simulator on a 32GB Mac.

Trade-offs

Flat navigation gives up the free state-keeping of nested stacks — returning to a screen re-fetches instead of finding everything still mounted. That’s a cost I took deliberately: SWR revalidation makes the re-fetch cheap and current, while the old behavior paid for instant back-navigation with unbounded memory. And the stabilization was spread over months of normal releases rather than a big-bang rewrite, which meant living with the leak while the fix landed piece by piece — slower, but the app kept shipping.

Result

The app now idles around 350MB and peaks near 400MB — on everything up to and including the 2018 Android — and hasn’t been OOM-killed since. The same effort shipped the stabilization work around the leak: global error boundaries, crash-safe startup, and an end to spurious logouts on transient network errors.

The full technical teardown — what the leak looked like, why the easy fixes weren’t enough, and the review pass that caught the long tail — is in the blog post Cutting a React Native app from 1.2GB to 350MB.

What I’d do differently

Profile on a real low-end device from day one. The simulator’s indifference to memory cost me the first weeks of misplaced confidence, and the honest numbers only appeared once the 2018 Android entered the loop.

Related case studies

Want to talk about a role or a system like these?