Five bugs that only existed in production
A tracking prompt iOS silently dropped, StoreKit hanging forever, purchases that worked offline until they did not, and other things no simulator will show you.
Everything here passed in development. Some of it passed in TestFlight. Each one needed a real device, a real store account, or a real network condition to reproduce, which is the category of bug that makes shipping a mobile app slower than it looks.
These are from Overbloom, an Expo game I released this year.
The tracking prompt that never appeared
App Tracking Transparency is a hard requirement if you serve personalised ads.
Call requestTrackingPermissionsAsync(), wait for the answer, then start the ads
SDK. Mine ran at launch and worked on my phone.
On iPad it did not. No prompt, no error, nothing in a log. iOS silently drops
the request if the app has not reached UIApplicationStateActive yet, and iPadOS
has a slower cold launch transition, so the request was landing in the window
before the app was properly foregrounded.
Silence is the problem. A thrown error would have shown up in a log the first time. Instead the call resolves, the code proceeds, and the only symptom is that some devices never see a prompt they are legally required to see.
The fix is to wait for the app to actually be active:
// requestTrackingPermissionsAsync() was firing during launch before the app
// reached UIApplicationStateActive, so iOS (notably iPadOS, with its slower
// cold-launch transition) silently dropped the prompt.
await waitForActiveAppState(); // AppState 'active' + a short settle delay
const { status } = await requestTrackingPermissionsAsync();
await mobileAds().initialize(); // strictly after the ATT response resolvesThe ordering in that last line is not optional either. No SDK that collects an advertising identifier may start before the response resolves.
While I was in there I also replaced the plugin’s generic default usage
description with an explicit NSUserTrackingUsageDescription. The default is
technically valid and reads like boilerplate, which is not what you want on the
one screen where you are asking a person for something.
StoreKit hanging with no timeout
A second purchase, started immediately after a first one completed, could wedge the buy spinner forever.
purchase(confirmIn:) can silently never return if you call it while the
previous purchase’s confirmation UI is still tearing down. No error, no
rejection, no event. The promise simply never settles, and there was no recovery
path, so the spinner stayed up until the app was killed.
Three changes, and only one of them is the actual fix:
// A 90s watchdog frees the UI, buys are serialized behind the previous
// finishTransaction plus a 1s cooldown, and finish failures are logged instead
// of swallowed.Serialising purchases behind the previous finishTransaction plus a cooldown is
the fix. The watchdog is an admission that I do not fully trust the platform and
would rather show an honest error than an infinite spinner. Logging the finish
failures is how I would find out about the next one of these, since swallowing
them is what made this invisible for so long.
Buying things offline
Then a report of stacked alerts, arriving minutes after the player had left the store.
With no network, buy() still went through to requestPurchase. The
connected flag stays true when the network drops, because the store connection
is local to the device. The request then hangs with no outcome event, and the
only thing that eventually fires is the 90 second watchdog. Once per tap. A
player tapping four times offline gets four alerts, all arriving long after they
have moved on.
The preflight is a capped refetch of a single product before starting a purchase. An unreachable store now fails in a beat with one honest message saying nothing was charged, and never arms the watchdog.
Two smaller things came out of the same fix. Purchase errors with no purchase in flight are now ignored rather than alerted, because replayed transactions and reconnect queue flushes both produce error events nobody asked for. And only one purchase alert can be on screen at a time, which would have contained the symptom even without the root cause fix.
The general shape here is worth keeping: a connection flag that means “we initialised successfully” is not the same as “requests will complete”, and the gap between those two is where offline bugs live.
The alerts nobody asked for
Worth separating out because it is the cheapest lesson.
Store SDKs replay events. Transactions from a previous session, queue flushes on reconnect, restores. If your error handler unconditionally shows an alert, the player gets an alert about a purchase they made last week, on launch, for no apparent reason.
Handlers for platform events need to know whether the app is currently expecting one. Without that state, you cannot tell a real failure from an echo.
Ads held in memory for the whole run
Not a crash on its own, but it contributed to several.
Rewarded and interstitial creatives were preloaded at boot and again as soon as an ad closed. A warm video creative is tens of megabytes of resident native memory, and it sat there for the entire play session waiting to be useful.
On a 3GB iPhone that is a meaningful slice of the budget, and it stacked with other native allocations to push long sessions over the line. Loading at the death transition instead means the load window is covered by the results sequence, which is also the only point where an ad is about to be shown.
The instinct to preload aggressively comes from wanting the ad to appear instantly. It is worth checking what that instinct costs on the devices you actually have players on.
The pattern
Four of these five share a shape. The platform did something quiet: dropped a prompt, never resolved a promise, replayed an old event, kept memory warm. None of them threw, so none of them appeared in a log or a crash report.
The practical response is to stop trusting that quiet means fine. Every await on a platform SDK gets a timeout. Every event handler asks whether it expected the event. Every flag gets read as what it literally means rather than what you hoped it meant.
That is more defensive than I would write for my own code, and it is roughly the right level of paranoia for someone else’s.
Overbloom is on the App Store, with all of this fixed.