Summary
The issue described is a non-atomic transaction finalization problem within the StoreKit 2 transaction lifecycle. The core symptom is that transaction.finish() appears to be silently failing or not persisting state in TestFlight environments when using personal Apple IDs, causing transactions to reappear in Transaction.unfinished upon relaunch. This results in duplicate purchase opportunities or phantom ownership states after reinstallation. The root cause is not a StoreKit bug, but rather a logic gap in how finish() is called within concurrent execution contexts and a misunderstanding of the verification lifecycle.
Root Cause
The root cause is a race condition in transaction handling combined with missing await keywords in the iteration logic provided in the example code.
- Missing
awaitin Iteration: In the providedcheckedTransaction()code, the linefor await result in Transaction.unfinishedlacks anawaitkeyword beforeresult. WhileTransaction.unfinishedis an asynchronous stream, the logic inside the loop fails to properlyawaitthe asynchronousfinish()call in a robust manner, leading to potential suspension points being ignored during app termination or backgrounding. - Lack of Completion Handler Persistence: In the
buyProductfunction,transaction.finish()is called immediately afterhandleTransaction. If the app is suspended or terminated by the OS shortly after purchase (common in TestFlight testing where resources are reclaimed aggressively), the task executingtransaction.finish()may be cancelled before the StoreKit internal state is persisted to disk. - Sandbox vs. Production Discrepancy: In the production environment, the OS and StoreKit daemon maintain stricter state persistence. In the TestFlight sandbox environment (especially with personal Apple IDs), state reconciliation is less aggressive, making the race condition more visible. If
finish()is not awaited and completed fully, the transaction remains “unfinished” in the local transaction log, triggeringTransaction.unfinishedagain on relaunch.
Why This Happens in Real Systems
In real systems, asynchronous execution is non-deterministic.
- App Lifecycle Aggression: Mobile operating systems (iOS) aggressively suspend and terminate background tasks to preserve battery and memory. A
Taskin Swift concurrency is a cooperative task. If the OS terminates the app immediately after a purchase but before thetransaction.finish()suspension point completes, the transaction is never marked as finished. - Idempotency and Retry Logic: StoreKit 2 is designed to be idempotent. If a transaction is not finished, StoreKit assumes the app failed to process it fully (e.g., due to a crash). Upon relaunch, it resurfaces the transaction in
Transaction.unfinishedto ensure the user receives their entitlement. - Sandbox Environment Inconsistencies: The TestFlight sandbox environment behaves differently than the production App Store environment regarding transaction verification and caching. Personal Apple IDs in TestFlight sometimes exhibit “sticky” transaction states where
finish()is acknowledged but not committed immediately, requiring the app to handle the transaction logic defensively.
Real-World Impact
- Broken User Experience: Users see the purchase sheet again after a successful purchase, leading to confusion (“Did I pay twice?”).
- Revenue Loss Risk: If the logic incorrectly treats a failed finish as a new purchase, you might grant access without charging (though in this test case, it’s a sandbox issue).
- Support Overhead: Users reinstalling the app find their purchases “gone” (because the previous transaction wasn’t finished, so the restore process doesn’t see it), leading to refund requests and support tickets.
- Testing Paralysis: Developers cannot reliably test purchase flows in TestFlight, blocking release cycles.
Example or Code
The fix requires ensuring await is used correctly and guarding against multiple executions. Here is the corrected implementation pattern.
import StoreKit
public func buyProduct(_ product: Product) async {
do {
// 1. Attempt purchase
let result = try await product.purchase()
switch result {
case .success(let verification):
// 2. Verify the transaction
switch verification {
case .verified(let transaction):
// 3. CRITICAL: Update app state (entitlements) BEFORE finishing
await handleTransaction(transaction)
// 4. CRITICAL: Finish the transaction immediately after state update
// We do not await this inside the switch if we want to return to user,
// but we must ensure the task completes.
// For robustness, we finish inside the same async context.
await transaction.finish()
case .unverified(_, let error):
// Handle verification failure
print("Transaction unverified: \(error)")
}
case .userCancelled:
// Do nothing
break
case .pending:
// Handle family controls or pending approval
break
@unknown default:
break
}
} catch {
print("Purchase failed: \(error.localizedDescription)")
}
}
public func checkUnfinishedTransactions() async {
// Iterate using the async sequence properly
for await result in Transaction.unfinished {
switch result {
case .verified(let transaction):
// Update app state if needed (e.g., user already owns this)
await handleTransaction(transaction)
// Finish the transaction to clear the queue
await transaction.finish()
case .unverified(_, let error):
// Log error but do not crash
print("Unverified unfinished transaction: \(error)")
}
}
}
How Senior Engineers Fix It
Senior engineers approach this by implementing a transaction observer pattern that is resilient to app lifecycle changes and OS inconsistencies.
- Decouple State from UI: The
handleTransactionfunction (updating user defaults or database) must be atomic and idempotent. It should check if the entitlement is already granted before granting it again. - Global Transaction Listener: Instead of only checking transactions at app launch or purchase, implement a
TransactionObserverthat listens toTransaction.updates. This stream captures transactions from all sources (purchase, restore, family sharing). - Defensive Finishing:
- Never finish a transaction until the app’s internal state (entitlements) is fully updated and persisted.
- Always finish verified transactions, even if unverified ones occur (log the unverified ones only).
- Await the
finish()call within the sameTaskscope as the purchase to prevent suspension from killing the process mid-stream.
- Refresh Logic: On every app launch (or foreground event), explicitly run
checkUnfinishedTransactions()to clean up any “stuck” transactions from previous sessions, ensuring the user’s state is correctly restored. - Handle Pending States: Explicitly handle
.pendingstates (e.g., for parental approval). These transactions are not finished until the user completes the action externally.
Why Juniors Miss It
Junior developers often miss this because they treat StoreKit 2 as a synchronous network call rather than a complex state machine.
- Assumption of Immediacy: They assume
transaction.finish()is an instant command. They don’t account for the OS suspending the app before the command is flushed to the StoreKit daemon. - Ignoring the Async Stream: The
for awaitsyntax is new and confusing. Juniors often treatTransaction.unfinishedas a static array or a one-time fetch, missing that it is a continuous stream of events that requires proper asynchronous handling. - Lack of Lifecycle Awareness: They fail to realize that
buyProduct()is just one entry point. Transactions can enter the system via “Family Sharing” or “Restore Purchase” while the app is in the background. Without a global listener (Observer), these are missed, andfinish()is never called, causing the “zombie transaction” effect. - Testing Bias: They test in the Simulator (where StoreKit behaves differently) or with simple sandbox accounts, missing the nuances of TestFlight personal ID behavior where transaction persistence is less robust.