Skip to main content

Brick protection

A bricked app — one that launches into a white screen or crash loop — is the worst outcome of any OTA system. Swiftpatch is architected so this outcome is unreachable by design, not merely discouraged.

The guarantee: the store-bundled JavaScript always runs as a floor. Even if every Swiftpatch release on the device is corrupted, your users see a working app.

The three-slot layout

Every device running the Swiftpatch SDK has three bundle slots on disk:

┌─────────────────────────────────────────────────────────────┐
│ Binary slot (immutable) │
│ • JS bundle embedded in the .ipa / .apk │
│ • Installed by the App Store / Play Store │
│ • Always present, never modified, never deleted │
│ • THE FLOOR — the app can always fall back here │
├─────────────────────────────────────────────────────────────┤
│ Stable slot │
│ • The last OTA release that survived crash detection │
│ • Promoted from NEW after N successful cold starts │
├─────────────────────────────────────────────────────────────┤
│ New slot │
│ • The most recent downloaded OTA release │
│ • Staging area — becomes Stable after surviving │
└─────────────────────────────────────────────────────────────┘

The binary slot is the floor. On Android, super.getJSBundleFile() returns the bundle embedded in the APK. On iOS, Bundle.main.url(forResource: "main", withExtension: "jsbundle") returns the bundle embedded in the IPA. Swiftpatch's override calls these as a last resort — if every OTA slot fails, the app boots on the factory bundle.

The boot counter

Layer 1 of the safety net.

On every cold start, the SDK increments a boot counter stored in native preferences before JS begins executing:

Cold start begins

bootCounter++ (native, synchronous)

if bootCounter > maxCrashesBeforeRollback (default 2):
fall back to previous slot → reset counter → emit telemetry

Load active slot

JS starts

If the active OTA slot crashes hard enough that the process dies before JS can signal "I'm alive," the counter increments on the next launch. Three crashes in a row (default) and the SDK drops to the previous stable slot. If that also crashes, it falls to the binary slot.

The mount marker

Layer 2. Reaching JS start is not enough — a bundle can crash 2 seconds into execution (a reducer throws, a dependency is missing, a native module's API changed). So the SDK also arms a mount watchdog:

  1. On load, the native side records that the app attempted to mount.
  2. JS executes. Your app renders.
  3. Once the JS has been alive for crashDetectionWindowMs (default 10s) without crashing, the SDK calls markMounted(), which clears the mount marker and boot counter.
  4. If JS crashes before markMounted() fires, the marker stays set.
  5. On the next cold start, native sees the marker still set → the active slot is bad → revert to the previous slot → reset counter.
// SwiftPatchProvider calls markMounted() automatically after initial render.
<SwiftPatchProvider config={{ debug: __DEV__ }}>
<App />
</SwiftPatchProvider>

If you're using the lower-level imperative API, call NativeSwiftPatch.markMounted() yourself after first successful render.

The async-signal-safe crash handler

Layer 3. The mount marker catches crashes that happen before the watchdog fires. But what about a crash at 9.999 seconds? The SDK installs a native crash handler:

  • iOS — a C shim attached to SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE. Async-signal-safe — uses write(2) with a pre-opened file descriptor, not fprintf.
  • Android — a JNI breakpad-style signal handler installed during native module load.

On crash, the handler writes a minimal crash marker to disk before the process dies. The next cold start sees the marker, attributes the crash to the active slot, and rolls back.

Native-version pinning

Layer 4. If you ship a JS bundle that requires a newer native binary than the device has, the JS will crash at the first require() of the missing native module. Swiftpatch handles this with native-version pinning:

  • Every release in the dashboard has a minNativeVersion field.
  • The SDK reports its native version on every checkForUpdate call.
  • The server never serves a release whose minNativeVersion exceeds the device's native version.

Result: a JS bundle that needs native changes is invisible to older binaries. Users on the old binary keep running the last compatible OTA until they update from the store.

Full lifecycle — bad bundle recovery

Cold-start with pending bundle r_xyz


Native: bootCounter++, load pending → active, arm watchdog (10s)


JS runs

├── Alive for 10s → markMounted() → counter cleared, marker cleared
│ Bundle r_xyz is now VERIFIED. Normal operation.

└── Crash before 10s


Next cold start:
├── Native: mount marker still set → r_xyz is BAD
├── Revert active slot to previous stable slot (or binary)
├── Emit ROLLBACK_PROD telemetry event
├── Server: if ≥N devices report bad on r_xyz in a 15-min window:
│ → auto-pause rollout
│ → alert release owner (email + dashboard incident)
└── User sees the previous working app. Never sees r_xyz again.

What this guarantees

  • Binary slot is always viable. Your users always have a working app floor. Even if Swiftpatch's servers are down and every cached release is corrupted, the store build runs.
  • Bad bundles are locally recovered. The device reverts itself without waiting for the backend. No server round-trip means no dependency on connectivity during recovery.
  • Blast radius is bounded. The server auto-pauses the rollout the moment multiple devices report a bad bundle. The fraction of users who ever see a bad release is near-zero at any non-trivial rollout percent.
  • Signatures protect the pipeline. A release that does not pass Ed25519 signature verification is never promoted to active in the first place.

What it does not guarantee

Brick protection cannot recover from:

  • A signed, non-crashing bundle with a logic bug — the bundle runs, markMounted fires, and the SDK considers it healthy. Your users see the bug. Rollback with swiftpatch releases rollback.
  • A store-build crash — if your binary itself is broken, Swiftpatch cannot save you. Ship a new store build.
  • Custom Error.captureStackTrace-swallowing code — if your app silently catches every error, the mount marker still clears. Crashes that aren't crashes cannot be auto-detected.

Configuration

<SwiftPatchProvider
config={{
crashDetectionWindowMs: 10_000, // How long JS must survive for markMounted (default 10s)
maxCrashesBeforeRollback: 2, // Cold-start crashes before rolling back (default 2)
}}
>
<App />
</SwiftPatchProvider>

Most apps should leave these at defaults. Tune only if you have unusual cold-start patterns.

Next steps