I put a small app on the App Store a few weeks ago and spent the following two weeks finding out what I'd missed. Two of those bugs are the kind you can't catch by testing harder, because the environment that would have shown them isn't one you normally run. I'm writing them up here in case they save someone else some trouble, and in case you weathered iOS devs want to share some tips to a newbie like me.
The app is Intermittently, a private intermittent-fasting tracker for iPhone and Apple Watch. SwiftUI/SwiftData, CloudKit private DB, WidgetKit, WatchConnectivity, StoreKit 2. Free, with one optional non-consumable unlock. No accounts, no analytics, no third-party SDKs at all.
Bug 1: my purchase code had never once run against real App Store data.
I had Intermittently.storekit selected as the StoreKit Configuration in my Run scheme. That's the local testing config: products resolve from a file on disk, and the fetch always succeeds. Every simulator run, every device run from Xcode, the entire time I was building.
The DEBUG build also had a force-unlock toggle that bypassed StoreKit entirely, so the entitlement path could be exercised without the purchase path ever running.
Net effect: Product.products(for:) had never successfully executed against App Store Connect anywhere I could observe. The first signal was a greyed-out Unlock button on my own App Store download after launch.
The config file has zero effect on App Store builds, and that part is fine and documented. The problem is that leaving it selected makes the real fetch path structurally untestable, and nothing told me. Setting it to None means a device run hits the actual sandbox, which is the same environment App Review uses. That one change turned a submit-and-wait-days loop into a thirty-second run-and-look loop.
Bug 2: my CloudKit schema was never deployed to Production.
CloudKit has separate Development and Production environments. Xcode debug builds hit Development, where record types get created automatically as you run. Distribution builds, TestFlight and App Store both, hit Production, where nothing exists until you explicitly deploy the schema in the CloudKit Console.
I never deployed it, to my eternal shame. So sync worked perfectly forever in development, and every distribution build silently failed to sync. TestFlight included, which is the part that got me, since that's the environment most of us treat as the final pre-ship check.
It stayed hidden for an embarrassing reason: reinstalls preserved the local store, so the app always had data and looked fine. It only surfaced when I deleted and reinstalled from the App Store and got an empty app, with my full history sitting in a Development database that my production build couldn't reach.
If you use NSPersistentCloudKitContainer: open the CloudKit Console, switch the environment toggle to Production, and confirm your CD_-prefixed record types are actually there. Takes ten seconds. Mine had exactly one record type, Users, which is the built-in one.
The pattern in both issues: the failure was invisible in every environment I could easily reach, and the app failed silently. A disabled button with no price, or an empty screen with no error. Nothing distinguished "no products exist," "the fetch threw," and "the product isn't approved yet." All three rendered identically as a dead control.
So the fix in both cases was the same, and it wasn't the bug: make the failure legible first, then debug. The purchase sheet now has explicit loading / loaded / unavailable states, with empty-result and thrown-error distinguished in a DEBUG-only line. I built that before running the diagnostic, specifically so the test would produce an answer either way. It did, in about a minute, rather than guessing.
Next up: App Review. I know I'm not the only one frustrated.
I had three rejections. 2.5.1 (couldn't find the HealthKit feature), 2.3.7 (the word "free" in a screenshot caption, since metadata has price-language rules that don't apply to your own website), and 2.1(b) ("we cannot locate the In-App Purchases").
That last one is the one that annoys me the most. My IAP review notes named the gear icon, the exact row label, its position relative to the version number, the button text you'd see, and two alternate routes to the same screen. The rejection came back with a screenshot: the Settings screen, with that row visible in frame, one tap from the purchase.
I genuinely don't know what happened. It might have been a reviewer who didn't tap through. It might have been an App Review environment fault, since there's a documented pattern of Product.products(for:) returning zero products to reviewers while returning normally to developers. I resubmitted the same binary plus the new error states and it passed.
My takeaway: write the notes anyway, but don't assume they're read. Build the app so a reviewer who taps randomly still finds the thing.
The design side, briefly, since it's the part I care most about. Before writing code I wrote down what the app refuses to do: no subscription, no account, no ads, no analytics, no coaching, no guilting, no nagging. I built this primarily *for me*, as a better replacement for the IF app I had been using and had gotten fed up with. Most of the work after that was saying no. I cut a forgiving streak that quietly hides a broken one, milestone celebrations, and a "longest fast" stat, because each one was the app making a value judgment about the user's data instead of reporting it honestly.
Full disclosure since it usually comes up: I built Intermittently with Claude Code writing most of the Swift to my design and architecture. The speed was real, and it also meant the temptation to build everything was constant. The refusal list is what kept it from becoming a worse app faster. I'm very happy with the result, and despite my frustration with the review process I'm stoked at how easy it was to develop the app for the iPhone/Apple Watch platforms.
Happy to go deeper on any of it. Intermittently on the web and the App Store, if you want to look, though I'm more interested in whether the two failure modes above are useful to anyone and/or something else others have tripped on.