●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
iOS Memory Pressure on Rork Apps — A 5-Tier Release Architecture from 12 Years of Wallpaper Apps
A 5-tier memory release architecture for Rork-built iOS apps, refined while running wallpaper apps to a cumulative 50 million downloads. Includes the Instruments + MetricKit measurement flow that brought OOM rate from 4.3% to 0.36%.
This morning's Crashlytics alert had the same pattern at the top again. "Out of Memory (Background)" was the leading group, and the reproducing devices were iPhone SE (3rd gen) and iPad mini 6. The former ships with 4GB of physical RAM, and while the iPad mini 6 also has 4GB, iPadOS multitasking is strict enough that the OS terminates the process within seconds of a memory warning.
When you run wallpaper apps, memory issues never really go away. Users keep tapping "next wallpaper, next wallpaper," cycling through high-resolution images one after another. With original source material around 3,000 x 5,000 pixels, a single decoded bitmap reserves over 60MB of memory, and rendering five or six in a list view is enough to start brushing against physical memory limits.
I've been building iOS and Android apps as an indie developer since 2014. Cumulative downloads have crossed 50 million, with most of that coming from wallpaper, healing, and law-of-attraction style image-centric apps. Even after I moved the recent lineup to a React Native base via Rork, the memory characteristics of "images are heavy" haven't changed. If anything, the JS bridge adds another layer, so without a release strategy that coordinates with native code, you find yourself unable to react quickly enough once a memory warning fires.
What I've sharpened over these 12 years is a design I call the 5-tier release architecture. The push toward 1 million yen per month in AdMob revenue made OOM crashes painfully tangible — each one directly chipped at LTV. This article walks through the actual implementation I run in Rork-generated Expo projects, on both the Swift and TypeScript sides, along with the measurement flow that took my OOM rate from 4.3% to 0.36%. I'm Masaki Hirokawa, an artist and personal developer shipping on both App Store and Google Play for the last 12 years, and I've kept the operational realism as close to my actual notebook as I can.
Why staged release matters
iOS memory warnings come in two waves. First, a light warning is delivered as a UIApplication.didReceiveMemoryWarning notification. Ignore it, and within seconds jetsam will kill the process — with an even stricter threshold if you're in the background. Even in foreground, if you keep allocating after the warning, the app dies without applicationWillTerminate ever firing.
The naive response is "clear the entire image cache on warning." That does lower OOM rate, but the side effect crushes user experience. The moment a warning fires mid-scroll, on-screen images vanish, a flash occurs, and reload spinners appear. For a wallpaper app, that registers as "the whole app froze" to users.
That's why I introduced a design that splits the release target into 5 tiers and lets the app shed memory in stages depending on warning level and pressure on remaining RAM. The tiers are ordered by how much they hurt user experience, and only when things get genuinely tight does the app reach into the deeper tiers.
Tier 1: Disk cache and prefetched remote data — invisible to users, re-fetchable
Tier 2: Decoded bitmaps for off-screen views — image cache outside the viewport
Tier 3: Intermediate buffers for animations and transitions — visible to feel, but restorable
Tier 4: Thumbnails and adjacent prefetch beyond the visible range — supports scrolling feel, but rebuildable
Tier 5: High-resolution variants of currently visible images — last resort; swap in low-res placeholders
Releasing in this order minimizes perceived degradation while substantially reducing the probability of forced termination by jetsam. Across my apps, this design brought OOM rate from 4.3% to 0.36% (trailing 30 days, iPhone SE 3rd gen).
Receive on the native side — a Swift memory pressure observer
Even on Expo projects generated by Rork, adding a single native module lets you forward iOS memory warnings up to React Native. Start with the Swift listener.
import Foundationimport UIKitimport os/// Singleton responsible for the 5-tier release strategy.@objc(MemoryPressureManager)class MemoryPressureManager: NSObject { enum Tier: Int { case disk = 1 // Tier 1: disk cache case offscreen = 2 // Tier 2: off-screen views case animation = 3 // Tier 3: animation buffers case adjacent = 4 // Tier 4: adjacent prefetch case visibleHi = 5 // Tier 5: visible high-res variants } static let shared = MemoryPressureManager() private let log = Logger(subsystem: "design.dolice.memory", category: "pressure") private var lastReleasedTier: Tier = .disk private var lastReleasedAt: Date = .distantPast override init() { super.init() NotificationCenter.default.addObserver( self, selector: #selector(didReceiveMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) } @objc private func didReceiveMemoryWarning() { let footprint = currentFootprintMB() log.warning("memory warning at \(footprint, format: .fixed(precision: 1)) MB") escalateRelease(currentFootprintMB: footprint) } /// Decide the next tier to release based on remaining RAM /// and recent release history. private func escalateRelease(currentFootprintMB: Double) { let now = Date() let secondsSinceLast = now.timeIntervalSince(lastReleasedAt) // If we just released the same tier within 3 seconds, escalate one step. let nextRaw: Int if secondsSinceLast < 3.0 { nextRaw = min(lastReleasedTier.rawValue + 1, Tier.visibleHi.rawValue) } else if currentFootprintMB > 600 { // Past 600MB, start straight from Tier 3. nextRaw = max(lastReleasedTier.rawValue, Tier.animation.rawValue) } else { nextRaw = Tier.disk.rawValue } let tier = Tier(rawValue: nextRaw) ?? .disk notifyJS(tier: tier, footprint: currentFootprintMB) lastReleasedTier = tier lastReleasedAt = now }}
The interesting part is that escalateRelease considers both "what tier did we last release" and "the current physical footprint." When warnings come in rapid succession, starting from Tier 1 every time can never catch up. Conversely, reaching straight to Tier 5 on a long-overdue first warning breaks UX, so a time-based reset is built in.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A concrete implementation of staged release across 5 priority tiers triggered by iOS didReceiveMemoryWarning
✦The Instruments Allocations + MetricKit measurement flow that took OOM rate from 4.3% to 0.36%
✦Tier prioritization heuristics from running wallpaper apps to 50 million cumulative downloads, applicable to Rork-built React Native projects
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Receive on the React Native side — registering tier-specific handlers
The cleanest way to forward events from native to JS is to subclass RCTEventEmitter. Below is the TypeScript side that aggregates per-tier handlers. It slots directly into Rork's Expo Router output.
import { NativeEventEmitter, NativeModules } from 'react-native';type Tier = 1 | 2 | 3 | 4 | 5;interface MemoryEvent { tier: Tier; footprintMB: number;}type Handler = (event: MemoryEvent) => Promise<void> | void;class MemoryPressureBus { private handlers = new Map<Tier, Set<Handler>>(); private emitter: NativeEventEmitter; constructor() { this.emitter = new NativeEventEmitter(NativeModules.MemoryPressureManager); this.emitter.addListener('onMemoryPressure', this.handleNative); for (let t = 1; t <= 5; t++) { this.handlers.set(t as Tier, new Set()); } } register(tier: Tier, handler: Handler): () => void { this.handlers.get(tier)?.add(handler); return () => this.handlers.get(tier)?.delete(handler); } private handleNative = async (event: MemoryEvent) => { // Fire all handlers for this tier in parallel. // A slow handler must never block others. const handlers = Array.from(this.handlers.get(event.tier) ?? []); await Promise.allSettled(handlers.map((h) => h(event))); };}export const memoryBus = new MemoryPressureBus();
Each screen and each cache layer registers itself with the tier that fits its risk profile. The image cache subscribes to Tiers 1 and 2, animations to Tier 3, ListView prefetch to Tier 4, and so on. Components just register and unregister inside a useEffect.
A cache-layer example — pairing with Expo Image
The single biggest practical win in my apps has been controlling the Expo Image memory cache. expo-image exposes Image.clearMemoryCache() to drop decoded bitmaps in one call, and just registering it at Tier 2 substantially reduced the number of warnings that escalated past Tier 1.
import { Image as ExpoImage } from 'expo-image';import { memoryBus } from '@/lib/memoryBus';// Call once at app launch.export function registerImageCacheHandlers() { // Tier 1: disk cache (re-downloadable). memoryBus.register(1, async () => { await ExpoImage.clearDiskCache(); }); // Tier 2: decoded bitmaps in memory. memoryBus.register(2, async () => { await ExpoImage.clearMemoryCache(); }); // Tier 5: swap visible high-res to low-res. memoryBus.register(5, async () => { useLowResolutionFallback.getState().enable(); });}
Tier 5 is a heavy hammer, so I gate it carefully on the Swift side via currentFootprintMB. In my setup, Tier 5 fires only when foreground footprint exceeds 720MB. Computing the threshold dynamically from ProcessInfo.processInfo.physicalMemory is the safe move: 600MB on 4GB devices, 1.0GB on 6GB, and 1.4GB on 8GB devices.
Measurement flow with Instruments and MetricKit
None of this implementation matters without measurement on real devices. My division of labor is Instruments during development and MetricKit in production.
For the Instruments side during development:
Pick the device in Xcode, then Product → Profile → Allocations.
Script a typical user flow for the first 5 minutes after launch (scroll 50 wallpapers, favorite 20, bounce between settings 3 times).
Run VM Tracker alongside and record peak Resident Size.
Synthetically trigger memory warnings via xcrun simctl and verify that each tier releases as logged.
Capture screenshots showing Resident Size dropping after release as expected.
A caveat: Allocations only sees the Swift-side caches. The JS heap is invisible. If you're on Hermes, enable the Hermes sampling profiler via the react-native start flag and take a heap snapshot via Chrome DevTools separately.
In production I collect MetricKit's MXMetricPayload daily and stream it to BigQuery. The two signals I watch most are average footprint per cumulativeForegroundTime and MXAppLaunchMetric.histogrammedTimeToFirstDraw. After introducing Tier 2 release, the latter improved by 180ms at p95 — the kind of slow-burn UX improvement users notice without being able to name it.
import MetricKitfinal class MetricsReporter: NSObject, MXMetricManagerSubscriber { func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { guard let memory = payload.memoryMetrics else { continue } // Send averages and peaks to a Cloudflare Workers endpoint. // Always include device model, OS version, and app version. Task { await MetricsClient.shared.report( averageMB: memory.averageSuspendedMemory.averageMeasurement.value / 1_048_576, peakMB: memory.peakMemoryUsage.value / 1_048_576 ) } } }}
The concrete path from 4.3% to 0.36% OOM rate
Here's the order in which I shipped the work and the OOM rate at each stage. I keep this record in Notion because I can never remember afterward which change was the real lever.
Before any work (2025-11): OOM 4.3% / average Resident Size 540MB / Crashlytics has "OOM" at the top of the list
After Tier 1 (disk cache): 4.1% — within noise. By itself, this is barely a Band-Aid
After Tier 2 (decoded bitmaps): 2.4% — large improvement. The image cache was clearly the dominant factor
After Tier 5 (low-res placeholder swap): 0.9% — big drop on 4GB devices
After Tier 3 & 4 (animation buffers and adjacent prefetch): 0.36% — iPad mini 6 numbers finally cleaned up
The whole effort took about 3 weeks, but roughly 70% of the benefit came from Tier 2 alone. If you're an indie developer with limited time, my recommendation is to start with Tiers 1 and 2 together. Tiers 3 through 5 vary by device and usage pattern, so it's worth watching MetricKit numbers before investing the time.
Specifics for Rork-built projects
If you ship the Expo project that Rork outputs straight through EAS, Hermes is enabled by default. Hermes keeps the JS heap relatively small, but native-side caches are a separate concern, so this design still applies as is.
Two caveats to keep in mind. First, modules like Expo Image Picker and Camera that temporarily allocate large buffers expose their own release APIs (releasePreviewBuffers and similar). Register those at Tier 3 or 4; by default they hold onto memory and won't release on warning automatically.
Second, the Expo Modules threading model. An @objc(MemoryPressureManager) class instance is initialized on the main thread by default. NotificationCenter.default.addObserver must be called on the main thread anyway, so the code above is fine. But the JS event emitter dispatched from escalateRelease runs on the JS thread, so any work that needs hard real-time on the native side (such as releasing audio buffers) should stay on the Swift side end-to-end.
Next steps
When adopting this design in your own app, I recommend the following order.
Add a single native module that subscribes to UIApplication.didReceiveMemoryWarningNotification.
Set up memoryBus on the JS side and start with just the Expo Image Tier 1/2 registration.
Measure OOM rate before and after with Instruments to confirm the effect.
Add MetricKit so you can observe production memory peaks on a daily cadence.
Then layer in your app's specific cache layers (API responses, map tiles, audio) at Tiers 3 and 4.
Thanks for reading. I hope it helps you stabilize your own Rork-built apps.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.