
Paw Social ran in the simulator for weeks. Feed, onboarding, the meetups map, realtime chat. Then I asked the pipeline for a build that could exist on a phone I was holding, and it refused.
The React had not changed. The code the simulator was happily running was the same code the build was rejecting, which meant the problem lived in a layer I had never needed to look at: the gap between my code running and my code being allowed to run on hardware I do not own. Apple's technote on the subject is blunt about why that layer exists at all. Its platforms other than macOS will not run arbitrary third-party code, and the authorization that permits yours arrives as a provisioning profile answering who may sign, what they may sign, and where it may run. Expo draws the same line: EAS Build will produce an unsigned app, but distribution requires a signed one.
I spent that session learning I could not tell which layer I was in.
That is the moment the project changed for me, and not because signing is interesting. It is because I had never had to ask the question before. On the web, when something breaks, the answer is almost always my code, and when the answer is my code I can replace it in ninety seconds.
I kept a list while building. Application lifecycle. Permission timing. Backwards compatibility. Testing on real devices. I labeled it things iOS taught me, and the label was wrong.
Every item on that list was already true of the web app I had been writing for years. Not as a metaphor. Literally true, in the same product, in code I had already shipped. I had just never been charged for getting them wrong.
That is the whole argument here: iOS did not hand me new problems. It removed a discount I had been getting on old ones.
The size of that discount is set by one number, which is the cost of being wrong.
When recovery costs one deploy, a bad assumption never sends you a bill. It fails, you notice, you fix it, and the failure and the fix collapse into the same afternoon. You never have to find out that you were reasoning about the system incorrectly, because the incorrect reasoning got overwritten before it could cost anything. Cheap rollback does not eliminate the assumption. It pays for it, quietly, on your behalf, forever.
Raise the recovery cost past what you can absorb in an afternoon and every one of those assumptions gets itemized.
The two loops differ in length, which is obvious, and in termination, which is not. The web loop ends. The dotted edge on the iOS loop never closes: some population of old clients persists indefinitely, and no deploy of mine reaches them.
Four things got itemized. All four had been sitting in my web code the entire time.
Paw Social is one monorepo shipping two products: a Next.js web app and an Expo iOS app, talking to a shared API surface with the same Clerk user identity behind both.
That arrangement made an asymmetry impossible to keep ignoring. When I change the API, I can guarantee that every web client is on the new frontend within minutes. I cannot guarantee that for the iOS client, at any point, ever. Version one and version two of the app are both live, both authenticated, both calling the same endpoints, and the only thing separating them is whether a particular person happened to update.
Apple does not document this as a constraint anywhere I could find, which is telling. Expo states it plainly in its deployment docs: you should expect multiple binary versions in production, because "users do not always stay up to date with your latest store release". That is not a caveat. It is the operating condition.
So the shape of a backend change had to change. Not what does the client need, but what does the oldest client still in the wild need. Additive fields instead of renamed ones. New endpoints instead of changed contracts. Nothing removed until I have reason to think nobody is calling it.
Then the uncomfortable part. My web app had the same problem the whole time, and I had been treating it as theoretical.
Next.js documents it under the name version skew, and the failure list is specific: a client asking for JavaScript or CSS files that no longer exist, invoking a server function by an ID the new build does not recognize, or navigating with prefetched data from a deployment that is gone. The standard mitigation is a forced full reload when the client's deployment ID stops matching the server's, and the docs are honest that this throws away component state. That is a version-update prompt. I had been shipping one without ever calling it that.
Service workers make it plainer. A new worker installs in the background and then waits: it does not take over until no pages are still controlled by the old one, and refreshing the tab is not enough, because a refresh never leaves the old worker uncontrolled. The web.dev write-up reaches for exactly the analogy I had been resisting, comparing it to how Chrome itself updates in the background and applies on restart. And HTTP caching says the quiet part outright. RFC 8246 works through a newspaper photo cached for an hour and concludes that different users seeing different photos for up to an hour "is compliant with the caching mechanism." Serving a stale client is not a bug in the web. It is the specified behavior.
A web page felt like a process with a beginning and an end. Load, run, navigate away, gone.
iOS does not offer that model. UIKit moves a dismissed app to the background and eventually to a suspended state, and can then disconnect that scene at any time to reclaim its resources. The foreground has priority, so the system terminates background apps as needed to free memory for whatever the person is actually looking at. This is routine housekeeping rather than a failure, and you do not get a courtesy call: applicationWillTerminate is generally not invoked when a suspended app is killed. In React Native the part of this I actually touch is AppState, which reports active, background, and an iOS-only inactive for the moments in between.
My email-code auth flow walked straight into it. A person enters an address, leaves for their inbox, comes back, and expects to land exactly where they were. Every one of those steps is a chance for the OS to freeze or discard the thing they are coming back to.
Which forced a question I now think is the most useful one I got out of the project. I had been writing code that asked:
What happens when this request succeeds?
The question worth asking is:
What state can the application be in when the response arrives?
Those produce different code. The first assumes the app that made the request is the app that receives it. The second treats that as a claim requiring evidence.
Browsers do all of this too, and have for years. Chrome's own explainer for the Page Lifecycle work opens by naming the parallel: application lifecycle is how modern operating systems manage resources, apps on Android and iOS can be stopped at any time, and the web historically had no equivalent. It does now. A hidden page can be frozen, which suspends its timers and stops its fetch callbacks from running, and a frozen page can then be discarded outright while its tab keeps sitting there looking perfectly normal. Worth being precise about the standing of this one: frozen and discarded are defined in a WICG community group draft, not a W3C standard, and the events are Chrome-led rather than settled across browsers.
The rest is thoroughly standardized and I still was not designing for it. Back/forward cache restores a page from a snapshot of the JavaScript heap, pausing in-progress code and resuming it later, which is the browser doing precisely what iOS does to a suspended app. Timers in background tabs get throttled, down to once a minute in Chrome and to a 15-minute floor on Firefox for Android, which may unload the tab entirely. And the event I had been reaching for to save state is the wrong one: beforeunload is not reliably fired, especially on mobile, and the documented advice is to treat the transition to hidden as the last thing you can reliably observe.
Paw Social needs the camera for posts, the photo library for dog profiles, and location for map-based meetup discovery. Each of those requires a purpose string in the app's property list before the API will work at all, and Apple is direct about the two failure modes: without one, access fails and may crash the app, and App Review rejects binaries that touch protected resources without a stated reason. The same page carries the part that changed my thinking. If the person grants permission the system remembers and does not ask again, and if they deny it, subsequent attempts simply fail. Reversing that answer happens in Settings, somewhere I cannot reach.
That single property rearranges the engineering. Asking for location on launch, before anyone knows why the app wants it, is a permanent decision made at the worst possible moment with no retry. Apple's guidance says as much: avoid requesting permission at launch, and wait until someone uses the feature that needs it.
So permission timing stopped being a UI detail and became part of the flow's structure: earn the request, explain it in context, then ask, and design the feature so a refusal degrades it rather than breaking it.
The browser works the same way and I had been treating its prompts as free. A web permission resolves to granted, denied, or prompt, and once it is denied, my code is finished asking: there is no revoke method, a proposed one was removed, and the only path back runs through browser settings. Chrome's guidance states the intent behind that design plainly, which is that if sites could keep asking they would, so "recovering from the blocked state of a capability intentionally takes effort".
Browsers also actively punish the pattern I would have shipped. Chromium began quieting notification prompts for sites with very low opt-in rates, then enrolled sites with abusive requests automatically, and Lighthouse fails an audit outright for requesting notification permission on load. Ask badly on the web and you do not just lose that user's answer. You lose some of your ability to ask anyone.
The cost shows up in the numbers. Chrome's telemetry says 77% of desktop permission prompts appear with no signal of user intent behind them, and those prompts are allowed 12% of the time. Prompts that follow an actual interaction are allowed 30% of the time.

Chart: the same prompt, asked at a different moment, is roughly two and a half times more likely to be granted. Source: Chrome telemetry reported in web.dev, "Permissions best practices".
Two and a half times, for a change that costs nothing but ordering. I had been leaving that on the floor on the web for years, and it took a platform where the mistake was unfixable to make me go look.
The simulator ran the app. It also ran it on a large screen, on my desk, on my home network, at whatever speed a laptop with sixteen gigabytes of memory feels like.
Holding the build changed what I noticed. Tap targets I had sized by eye were too small for a thumb. The keyboard covered a field I had never scrolled to. Transitions I had tuned while staring at them felt slow when I was not staring at them. None of that was a bug. All of it was the product.
The web version of this mistake is the one I had been making for years without calling it a mistake: a fast laptop on a fast connection is not a measurement, it is a demo.
Chrome's documentation on lab versus field data puts it in terms that are hard to argue with. A lab test is one device, on one network, from one location, and controlling those variables means you are explicitly not representing the variance real users have. Field data, from something like the Chrome UX Report, reflects the devices and networks people actually hold. When you have both, the guidance is to prioritize with the field data. My simulator was a lab. So was my browser.
I do not want to oversell the symmetry, because the argument has a real boundary and the boundary is where most versions of this take fall apart.
Mobile is not uniformly less forgiving, and over-the-air updates restore a real piece of the recovery loop. Expo's docs draw the line as two layers: a native layer baked into the binary and a swappable update layer on top of it. Anything in the second layer can be replaced on installed apps. EAS Update's own table marks JavaScript bug fixes, copy, translations, styling, and screen layouts as shippable that way, and marks native code, Expo SDK upgrades, and anything needing a new binary as not.
Sitting in that second column, unfortunately, is any change to app permissions. The lesson I described two sections ago is specifically the kind I cannot fix over the air. Getting permission timing wrong means a new build, which is a fairly pointed reminder that the discount does not apply where I most wanted it.
Nor is the delay the real cost. Apple says 90% of submissions are reviewed in less than 24 hours, and Expo notes that OTA updates still have to obey store rules rather than routing around review. The queue was never what made mobile mistakes expensive. What makes them expensive is that shipping a fix and a person receiving it are separate events, and only one of them is up to me.
The web comparison has limits too. If you serve server-rendered pages to short-lived sessions with no long-lived client and no separate consumer of your API, you genuinely do not have an old-client problem, and inventing one is a good way to add complexity that pays you nothing. The audit below is worth running because most of its rows will come back clean. The point is finding the one that does not.
And I would not have found any of this by reading about it. Nothing above was unavailable to me before. It was all documented, in specs I could have read at any point in the previous several years. What changed was that being wrong finally cost more than being incurious, which is a worse reason to learn something than I would like to admit.
Here is what I went looking for in the web app afterward. Each row is a question that iOS priced for me and the web had been absorbing.
| What iOS charged me for | The same assumption on the web | Where to look |
|---|---|---|
| Users stay on old app versions | Version skew: a client that loaded last week's bundle still calls today's API | Long-lived sessions, service-worker-controlled clients, cached immutable assets, any non-browser API consumer |
| The OS suspends and discards apps | Hidden tabs get frozen, discarded, and restored from a heap snapshot; timers throttle | State that assumes continuous execution, beforeunload used to save anything, work that must survive going hidden |
| A denied permission is permanent | A denied permission cannot be re-prompted from your code either | Any request that fires on load instead of on intent |
| Purpose strings are mandatory | Nothing forces you to explain why you are asking, and the allow rate pays for it | Every prompt a first-time visitor sees with no preceding context |
| The simulator is not a device | Your laptop is a lab, not a measurement | Field data next to your own machine's numbers |
| The pipeline has layers | Build, cache, CDN, and runtime are separate places to be wrong | Which layer you actually reason about first when production misbehaves |
If you want the version of this that is specifically about distribution and the week that disappears into it, I wrote that separately in The Last 10% of Shipping an App Takes 50% of the Work. This one is about what came home afterward.
The debugging session I opened with stuck with me for a reason that has nothing to do with build credentials, and it took me the rest of the project to work out what it was.
The reason I could not find the problem is that I had spent years in an environment where I never had to know which layer I was standing in. One question, asked once, resolved it: is this my code, or is this the system around my code? I had not needed that question on the web, because the answer had been the same for so long that I stopped asking.
It is not that the platform taught me more than my web work did. It is that the platform charged me for the things my web work had been giving away, and I do not seem to learn from anything I am not billed for.
Thanks for reading.
More writing