ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-07-04Advanced

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.

StoreKit 24iOS27in-app-purchase2Antigravity308app-dev47

Premium Article

"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 StoreKit
import SwiftUI
 
@MainActor
final 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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

App Dev2026-06-23
When StoreKit 2 Users Say They Paid but Can't Access — Field Notes on Subscription Entitlement Drift
StoreKit 2 subscriptions are harder to operate than to implement. This is a record of the drift between currentEntitlements, subscription.status, and server notifications — and the reconcile logic that finally stopped the support tickets.
App Dev2026-05-26
Unifying In-App Review Prompts Across 5 Apps with Antigravity Editor — A Few Days of Notes
Notes from a few days spent unifying SKStoreReviewController trigger conditions across five iOS wallpaper apps I run as an indie developer, using Antigravity Editor's multi-file editing to bring the logic onto one shared coordinator.
App Dev2026-04-05
Don't Stop at Shipping StoreKit 2 — Growing Subscription Revenue with Server Notifications, Recovery Offers, and Price Experiments
Subscription revenue is decided after launch. Build Server Notifications v2 handling, cohort churn analysis, Promotional Offers, price experiments, and win-back automation — with production code and hands-on operational lessons.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →