◉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-purchaseAntigravity377app-dev54

✦ 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 $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-05-25
One Month Splitting Antigravity's Inline Edit and Agent Mode Across Four Wallpaper Apps
A month of notes from running Antigravity's Inline Edit and Agent Mode across four production wallpaper apps — with real counts, the decision rule I wrote into AGENTS.md, a one-pass dSYM fix, and how credit cost factors in.
▣ App Dev2026-04-09
Antigravity × App Clip, WidgetKit & Live Activities: Designing an iOS Monetization Funnel
Learn how to design an iOS monetization funnel using App Clip, WidgetKit, and Live Activities with Antigravity. From cold user acquisition to paid conversion, covering StoreKit 2 and RevenueCat integration.
▣ App Dev2026-03-18
Building Production iCloud Sync Apps with SwiftUI and CloudKit in Antigravity
Build a production iCloud sync iOS app with SwiftUI and CloudKit in Antigravity. Covers CKRecord design, offline support, and the queryable-index and Development-to-Production schema pitfalls that break release builds.
📚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