Fixing 0x8badf00d Watchdog Kills That Wipe Out Rork Apps at Launch
The shape of the bug is always the same: the App Store build dies the instant a user taps the icon, you cannot reproduce it on your own iPhone, and Crashlytics shows EXC_CRASH (SIGKILL) with exception code 0x8badf00d. Apple intends that hex string to read as "ate bad food," and it is iOS telling you the watchdog killed the process for taking too long to launch.
What makes this one awkward is that it does not look like a bug in your code. The frame at the top of the stack is almost always a third-party SDK's init function, and that function is behaving correctly. iOS simply ran out of patience and killed the whole process.
React Native, Expo, and Rork put JavaScript bundle evaluation directly on the launch path, so there is less headroom than a purely native app has. Every SDK you add raises the odds. Here is how to tell the cases apart, and what actually moved the needle in apps I keep in production.
What Watchdog Actually Measures
iOS gives application(_:didFinishLaunchingWithOptions:) a budget in the 5 to 20 second range, picked dynamically by the device. On a recent iPhone in a normal room the practical ceiling is around 10 seconds; on a colder device with low battery it shrinks. If the call does not return in time, the system sends SIGKILL and records 0x8badf00d in the crash log. There is no warning, and no way to opt out.
For React Native and Expo, everything up to and including the first requireApplicationRegister from your JavaScript bundle gets counted against that budget. A Rork-generated template that boots AdMob, AppOpen, Firebase, RevenueCat, Sentry, and Branch synchronously from AppDelegate will sail past 8 seconds on a modern iPhone, which makes the older devices crash.
The trap is that your dev machine launches the same build in 2 seconds. The watchdog does not look at your average; it looks at this specific cold start on this specific device under the current thermal and battery conditions.
Confirming the Diagnosis From the Crash Log
In Crashlytics or Xcode Organizer the signature is unambiguous:
- Exception Type:
EXC_CRASH (SIGKILL) - Exception Codes:
0x8badf00d - Termination Reason:
FRONTBOARD 0x8badf00d ... (scene-create watchdog transgression: ...) - The triggered thread is usually thread 0 with frames inside
RCTCxxBridge,facebook::hermes::HermesRuntime,expo-modules-core, or+[FIRApp configure]
If you see those four marks together, stop investigating other crash categories. This is a launch-time block, not a memory issue, not a missing entitlement.
You do not have to guess the budget — it is printed in the log
The detail most write-ups skip: the termination reason string contains the exact allowance that device granted on that launch.
Termination Reason: FRONTBOARD 2343432205
<RBSTerminateContext| domain:10 code:0x8BADF00D explanation:
scene-create watchdog transgression: application<com.example.app>:9312
exhausted real (wall clock) time allowance of 19.43 seconds>
That 19.43 seconds is not a documented constant, it is what iOS actually gave this process under these conditions. Write the number down across a handful of crashes and you get the real floor your app is fighting, instead of a range from a WWDC slide. On the same build I have seen it swing from the high teens down to nine seconds.
Just as important: 0x8badf00d is not exclusively a launch code. The start of the explanation tells you which lifecycle transition was cut off.
| Start of the explanation | Where it was killed | First suspect |
|---|---|---|
scene-create watchdog transgression | Launch through first scene creation | Synchronous AppDelegate init, JS bundle evaluation |
scene-update watchdog transgression | Foreground resume and other scene updates | Synchronous IO on resume, blocking AppOpen preload |
process-exit watchdog transgression | Termination handling | Save work inside applicationWillTerminate |
If you fixed the launch path and the crash rate refused to move, it was very likely scene-update all along. Read the explanation before you touch code.
The Four Things That Block Launch in a Rork App
In my own postmortems, more than ninety percent of 0x8badf00d cases came from one of these four:
- A wall of synchronous SDK init calls in AppDelegate — AdMob, Firebase, RevenueCat, Branch, and Sentry chained on the same thread because every vendor's docs say "call this at app start."
- Synchronous reads from MMKV, AsyncStorage, or SQLite — pulling user preferences, theme, or A/B flags inside
didFinishLaunchingbefore returning. - AppOpen ad preload that effectively blocks — wrapping
GADAppOpenAd.loadin a structure that waits on the network before handing control back. - Heavy JavaScript top-level imports —
App.tsximporting a 1 MB constants module (huge JSON, hardcoded wallpaper catalogs, full i18n trees) before the first render.
The Rork starter does not always force this, but it is the path of least resistance, so it is where most projects end up.
The Fix: Push Everything Heavy Out of the Launch Path
Native side (AppDelegate or ExpoAppDelegateSubscriber)
// Risky — everything is synchronous and on the launch path
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[FIRApp configure];
[GADMobileAds.sharedInstance startWithCompletionHandler:nil];
[RCPurchases configureWithAPIKey:@"YOUR_REVENUECAT_KEY"];
[Branch.getInstance initSessionWithLaunchOptions:launchOptions
andRegisterDeepLinkHandler:^(NSDictionary *params, NSError *error){}];
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}Keep only the absolute minimum on the launch path, and defer the rest behind a dispatch_async so they run after the first frame.
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Keep crash reporting first so any later failures still get captured
[FIRApp configure];
// Defer everything else to after the first frame
dispatch_async(dispatch_get_main_queue(), ^{
[GADMobileAds.sharedInstance startWithCompletionHandler:nil];
[RCPurchases configureWithAPIKey:@"YOUR_REVENUECAT_KEY"];
[Branch.getInstance initSessionWithLaunchOptions:launchOptions
andRegisterDeepLinkHandler:^(NSDictionary *params, NSError *error){}];
});
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}Initializing Firebase first is intentional: if AppOpen or RevenueCat crashes a moment later, Crashlytics is already alive to record it.
JavaScript side
// App.tsx — get the first paint done, then load everything else
import { useEffect, useState } from "react";
import { View } from "react-native";
export default function App() {
const [ready, setReady] = useState(false);
useEffect(() => {
// Run after the first paint so the watchdog window is already closed
(async () => {
const [{ initAnalytics }, { initRemoteConfig }] = await Promise.all([
import("./bootstrap/analytics"),
import("./bootstrap/remote-config"),
]);
await Promise.all([initAnalytics(), initRemoteConfig()]);
setReady(true);
})();
}, []);
return <View>{/* a cheap splash-equivalent here */}</View>;
}Hermes handles await import() cleanly. In one of my wallpaper apps cold start dropped from 2.4 s to 1.3 s after deferring just two startup modules, and the 0x8badf00d rate on iPhone 8 went from 0.7% to 0.04%.
Reproducing TestFlight-Only Crashes Locally
Watchdog enforcement gets stricter when the device is cold, low on battery, and on a bad network. To make a stubborn 0x8badf00d show up on your bench:
- Drain a real iPhone below 20% before testing
- Enable "Settings → Developer → Network Link Conditioner → Very Bad Network"
- Fully terminate the app (background is not enough) and tap the icon
That sequence reproduces the iPhone 8 crash about three times out of five for me. Firebase Test Lab on a physical iPhone 8 works too, but it costs more, so I do the local trick first.
Proving It Is Fixed With Field Data, Not One Bench Device
Local reproduction is good for forming a hypothesis. It is not enough to claim the problem is gone, because one device tells you nothing about the distribution. MetricKit reports that distribution daily, straight from your users' hardware.
MXAppLaunchMetric.histogrammedTimeToFirstDraw hands you launches bucketed by duration rather than averaged. The tail is the whole point: if a bucket up near eight seconds keeps returning a non-zero count, there are devices reaching the watchdog even though your mean looks healthy.
import MetricKit
final class LaunchMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = LaunchMetricsSubscriber()
func start() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
guard let histogram = payload.applicationLaunchMetrics?
.histogrammedTimeToFirstDraw else { continue }
for case let bucket as MXHistogramBucket<UnitDuration> in histogram.bucketEnumerator {
let start = bucket.bucketStart.converted(to: .seconds).value
let end = bucket.bucketEnd.converted(to: .seconds).value
guard start >= 5 else { continue } // only report the danger zone
Analytics.logEvent("slow_launch", parameters: [
"bucket": "\(start)-\(end)",
"count": bucket.bucketCount,
])
}
}
}
// The watchdog's own reason string is available here too (iOS 14+)
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
for crash in payload.crashDiagnostics ?? [] {
guard crash.exceptionCode?.intValue == 0x8badf00d else { continue }
Analytics.logEvent("watchdog_kill", parameters: [
"reason": crash.terminationReason ?? "unknown",
])
}
}
}
}Calling start() from didFinishLaunching is fine. MXMetricManager.shared.add only registers a subscriber; it does none of the work this article is trying to evict from the launch path.
In an Expo or Rork project, add this Swift file to the native target through a config plugin. There is no need to bridge anything to JavaScript — reporting directly to Analytics is the shortest path.
One operational catch worth knowing before you wire it up: MetricKit delivers at most one payload every 24 hours, and only on a subsequent launch. Nothing arrives the day you ship. It is only useful paired with a phased rollout you are willing to hold for a day. I lost an afternoon assuming an empty dashboard the next morning meant I had wired the subscriber up wrong.
A Pre-release Checklist to Keep 0x8badf00d Out
Instruments → App Launch is the only honest pre-release measurement, and I run it on every release branch. The three gates I enforce are:
- App Launch (Pre-main + Time to first frame) under 1.8 s on a recent iPhone and under 3.5 s on SE second generation
- No new
awaitor synchronous file reads added insidedidFinishLaunching - No new heavyweight SDK appearing in
package.jsonon the launch path; if it must, it has to be lazy-loaded
In Crashlytics I split EXC_CRASH (SIGKILL) 0x8badf00d into its own velocity alert. If the rate per release ever crosses 0.05% I halt the phased rollout before it can hurt retention.
What to Do Next
If a recent release is showing 0x8badf00d in Crashlytics, open AppDelegate, pick one SDK init that is currently synchronous, and move it inside a dispatch_async(dispatch_get_main_queue(), …) block. Even moving a single one buys back noticeable headroom on older devices. Then look at App.tsx, pick the heaviest top-level import, and convert it to await import() inside useEffect. Those two edits, in that order, take the watchdog kill from a release-blocker to a non-issue in most Rork projects I have shipped.
Thank you for reading. I hope this helps the next person who sees that hex string in a crash log.