◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-25Advanced

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%.

Rork585iOS116Memory ManagementOOMPerformance25InstrumentsWallpaper Apps3Indie Developer11

✦ Premium Article

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.

  1. Tier 1: Disk cache and prefetched remote data — invisible to users, re-fetchable
  2. Tier 2: Decoded bitmaps for off-screen views — image cache outside the viewport
  3. Tier 3: Intermediate buffers for animations and transitions — visible to feel, but restorable
  4. Tier 4: Thumbnails and adjacent prefetch beyond the visible range — supports scrolling feel, but rebuildable
  5. 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 Foundation
import UIKit
import 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-09-05
Your Widget Extension Can Submit a BGTaskScheduler Request. It Just Cannot Register the Handler
Calling BGTaskScheduler from a widget extension compiles, submits, and returns true — and then nothing runs. Here is why registration belongs to the host app only, and how I moved refresh ownership back where it belongs across my wallpaper apps.
⬡ Dev Tools2026-08-02
When Two-Character Queries Silently Return Nothing: Measuring Japanese Search Indexes in an Expo App
SQLite FTS5's trigram tokenizer returns zero rows for Japanese queries shorter than three characters, without raising anything. I benchmarked linear scan, a bigram inverted index, and FTS5 over a 20,000-item catalog to find the real threshold.