Paid, but the Ads Won't Go Away — Closing a StoreKit 2 Transaction.updates Launch Race with Antigravity
How I traced a StoreKit 2 launch race that dropped transactions arriving right after startup, pinned the listener to the app's lifetime instead of a view's, reconciled with currentEntitlements, and let Antigravity turn it into a StoreKitTest regression suite.
"I paid, but the ads are still there." That single sentence landed in the support inbox of a wallpaper app I run as an indie developer at Dolice — a few times a month, quietly piling up. The payment logs said the purchase went through. Every time I tried to reproduce it in the App Store sandbox, the ads vanished cleanly on my own device. Bugs that refuse to reproduce leave a small stone sitting in your chest.
The cause was not the purchase flow itself. The transaction the app missed did not arrive at the moment of purchase — it arrived the next time the app launched. I had been observing StoreKit 2's Transaction.updates bound to a view's lifetime, and that binding was where the race lived.
This is the record of how I traced that race, moved the listener onto the app's lifetime, and finally handed the regression tests to Antigravity so it could never quietly return. I am writing it for anyone shipping subscriptions or one-time purchases with StoreKit 2, so you don't have to carry the same stone.
Why "payment succeeded, UI unchanged" happens
In StoreKit 2, transactions reach you through paths other than the direct return value of product.purchase(). Auto-renewals, delayed Ask to Buy approvals, purchases made on another device, refunds, and revocations. None of these appear in the purchase button's return value; they flow in through an async sequence called Transaction.updates.
The problem is delivery timing. An Ask to Buy transaction approved by a parent is finalized while the child's app is closed. On the next launch, StoreKit tries to deliver that transaction through Transaction.updates. If the listener is not already running at that moment, granting the entitlement falls behind.
My implementation started the listener inside the .task modifier of the SwiftUI view that managed subscription state. It looked tidy. But .task is tied to the view appearing and disappearing. When the view goes away, the listener is cancelled, and a gap opens where nobody is watching Transaction.updates until the user navigates back. The payment is settled on Apple's side, while only the in-app entitlement flag and the ad gating stay stale. That was the real shape of "paid, but the ads won't go away."
StoreKit holds unfinished transactions and redelivers them on the next launch, so no data is permanently lost. What is lost is the reader's trust. "I paid and nothing changed" is a perfectly good reason to never pay again.
Pin the listener to the app's lifetime, not a View's
The redesign comes down to one idea: fully detach transaction observation from which screen is showing, and make it a task that never stops while the app is alive. In SwiftUI, that means starting an uncancelled Task at the point the App initializes.
import StoreKitimport SwiftUI@MainActorfinal class EntitlementStore: ObservableObject { @Published private(set) var hasPremium = false // Observation tied to the app's lifetime. The Store holds it, not a View. private var updatesTask: Task<Void, Never>? func startListening() { // Guard against double-start. If it is already running, do nothing. guard updatesTask == nil else { return } updatesTask = Task.detached(priority: .background) { [weak self] in // This loop keeps spinning until the app terminates. for await update in Transaction.updates { await self?.handle(update) } } } private func handle(_ result: VerificationResult<Transaction>) async { guard case .verified(let transaction) = result else { // Never grant an entitlement from a transaction that failed verification. return } await refreshEntitlements() // Always finish once reflected. Skip this and redelivery never stops. await transaction.finish() }}
The key is calling Task.detached exactly once, right after the App starts. I create the EntitlementStore in the App's init() and call startListening() there. No matter how many views come and go, the observation loop stays a single, living thread.
The for await update in Transaction.updates loop is deliberately never cancelled. Apple's own guidance is to begin this observation as early as possible in app launch and keep it alive for the app's lifetime. Lean on the .task modifier and that "for the lifetime" shrinks to "only while visible."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦What the launch-time Transaction.updates race really is, and why the listener belongs to the app's lifetime rather than a View's
✦Working code for startup reconciliation via currentEntitlements and disciplined finish() calls that stop redelivery loops and stale entitlements in a 2-stage startup sequence
✦Reproducing Ask to Buy and auto-renewal with StoreKitTest's SKTestSession and having Antigravity write the XCTests that lock the race down
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Pinning the listener still leaves a hole. Transaction.updates is a sequence of updates that are "about to happen." Entitlements that were finalized while the app was closed, and are no longer sitting in the delivery queue, are not guaranteed to be re-emitted right at launch. The source of truth for currently valid entitlements is Transaction.currentEntitlements.
So I made the launch sequence two-staged. First sweep currentEntitlements to settle the UI against current entitlements, then hand off to Transaction.updates observation.
extension EntitlementStore { // Call once at launch. Reconcile against all currently valid entitlements. func refreshEntitlements() async { var owned = false for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil && transaction.productType == .autoRenewable { owned = true } // Pick up non-consumable one-time purchases through the same path. if transaction.productType == .nonConsumable { owned = true } } hasPremium = owned } // The entry point called right after the App starts. func bootstrap() async { await refreshEntitlements() // 1. Reconcile current entitlements. startListening() // 2. Hand off to observing future updates. }}
Forget the revocationDate check and you will treat a refunded entitlement as valid. This is exactly where I stumbled first. currentEntitlements can return transactions that have been revoked or refunded, so you must confirm revocationDate == nil. The absence of that one line creates the mirror-image bug: ads that stay gone even after a refund.
Skip finish() and redelivery never stops
await transaction.finish() is a plain line, but it is the most overlooked one in StoreKit 2. Until you finish() a transaction, StoreKit treats it as unprocessed and keeps redelivering it through Transaction.updates on every launch.
Redelivery itself is not harmful. What becomes harmful is a design that runs heavy work on every redelivery. My old implementation fired a server-side receipt check on every transaction received. Because I had forgotten finish(), devices holding an unfinished transaction ran that same check on every launch, quietly piling up wasted network calls and wait time.
The order is fixed. Reflect the entitlement in the app, finish any required server verification, and only then call finish(). Call finish() before granting the entitlement and you lose the redelivery safety net if the grant fails. Stamp the transaction complete only after you have confirmed the grant succeeded.
Let Antigravity reproduce the race itself with StoreKitTest
This is the part that keeps the bug from ever returning. Manual sandbox testing cannot deliberately reproduce a race like a delayed Ask to Buy approval landing while the app is not running. Xcode's StoreKitTest framework can — SKTestSession lets you inject approvals, renewals, and refunds from code.
I described the reproduction conditions to an Antigravity agent in plain words: "Approve an Ask to Buy purchase before the app launches, run the startup sequence in the order currentEntitlements reconciliation then updates observation, and verify hasPremium becomes true." The agent read the .storekit configuration file and assembled tests like these.
import StoreKitTestimport XCTest@testable import WallpaperAppfinal class EntitlementRaceTests: XCTestCase { private var session: SKTestSession! override func setUp() async throws { session = try SKTestSession(configurationFileNamed: "Products") session.clearTransactions() session.disableDialogs = true } // Verify the launch sequence does not drop a purchase finalized before launch. func testEntitlementResolvedAtLaunch() async throws { // Reproduce a purchase settled while the app was not running. try await session.buyProduct(identifier: "com.dolice.wallpaper.premium") // Run the startup sequence (reconcile, then observe). let store = await EntitlementStore() await store.bootstrap() let hasPremium = await store.hasPremium XCTAssertTrue(hasPremium, "Entitlement not reflected at launch (race regressed).") } // Verify the entitlement is correctly revoked after a refund. func testEntitlementRevokedAfterRefund() async throws { let result = try await session.buyProduct(identifier: "com.dolice.wallpaper.premium") let store = await EntitlementStore() await store.bootstrap() try session.refundTransaction(identifier: UInt(result.id)) await store.refreshEntitlements() let hasPremium = await store.hasPremium XCTAssertFalse(hasPremium, "Entitlement persists after refund (revocationDate unchecked).") }}
What made the delegation worthwhile was not having to hold the exact shape of the SKTestSession API in my head. I could focus my review on the meaning of the race. I did not simply trust the generated tests. I checked where clearTransactions() was called, whether disableDialogs was needed, and any missed async waits — and hand-corrected two spots. What to delegate and what to keep as human judgment. That line is, I think, the real knack for using this tool over the long run.
Two weeks of measured results
I shipped the redesigned build to the App Store and watched it for two weeks. Support messages to the effect of "paid, but the ads won't go away" — a few a week until then — dropped to zero. The mirror-image bug, where ads never came back after a refund, has also gone silent since I added the revocationDate check.
There was a side benefit. The wasteful receipt checks that had run on every launch disappeared, and on some devices that had been carrying an unfinished transaction, the stretch from launch to home screen felt lighter. It is not a difference worth bragging about in numbers, but it taught me again the weight of a single finish() line.
AdMob ad gating and purchase entitlements are, in principle, entirely separate layers. Yet they cross at exactly one point: "is this user a paying customer." Being able to pin that one judgment to the app's lifetime rather than the whims of a screen was the biggest gain here.
Your next move
If your app keeps a StoreKit 2 listener inside a view's .task, start by moving just that one thing to a task at App startup. The launch-time currentEntitlements reconciliation and the finish() ordering can be added afterward and still land in time. And the races that are hardest to reproduce are exactly the ones worth pinning down in StoreKitTest, so the conditions live in code as a regression test.
I am still learning the finer points of StoreKit 2 myself. If you carry the same stone as a fellow indie developer, I would be glad to quietly pool what we each know. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.