The 12-second gap that swallowed two subscriptions

The 12-second gap that swallowed two subscriptions
On 21 August a parent bought an annual Tempy subscription. The store took the payment, the entitlement went active, and twelve seconds later our database wrote the row that marks their family as premium. Our analytics recorded nothing at all. As far as every dashboard we own was concerned, nobody bought anything that day.
The problem
We noticed because a monthly number looked wrong, not because anything alerted. The last subscribe_start event we had received was dated 31 July. Two August purchases were missing.
The comfortable first read is that this is an analytics bug: somebody paid, we have their money, we just failed to write a row in a reporting table. Annoying, fixable later.
That read was wrong, and the way it was wrong is the point of this post. The missing event was not the failure. It was the symptom of a purchase path that had classified a successful payment as a failure, shown the parent no confirmation, and in one branch shown them an error message instead. At least two parents paid us and got, from their side of the screen, nothing.
Why it happened
There were four separate causes, and only the first one is interesting on its own.
The success path trusted a snapshot. Our purchase call read isPremium off the customer info the store SDK had just handed back. If the entitlement had not been attached to that particular object at that particular instant, the read returned false, and the caller treated a completed, paid purchase as a failed one.
The maddening part: the error path had refreshed the customer info for exactly this case, and had done so since before we migrated the SDK from 8.x to 10.x. The success path never did. Nobody wrote that asymmetry deliberately. It accumulated.
The recording lived in a widget. Every post-purchase side effect sat behind if (mounted && success) inside the paywall screen, so a disposed widget silently discarded the record of a completed purchase. Whether a parent paid us is not a UI concern, but it was living in the UI layer.
We could not tell "declined" from "lost". There was no event when the buy button was tapped, only one when the subscription started. So "the parent looked at the price and changed their mind" and "the parent paid and we lost the record" produced the identical observation: silence. That is why diagnosing a single purchase took database forensics.
The store version was implicit. Our purchase SDK configuration named no StoreKit version, so iOS purchase semantics were whatever the SDK happened to default to — and that default moved under the 8.x → 10.x upgrade with no diff in our repository at all.
What we tried first
The obvious fix is to make the success path refresh, the same way the error path always had. We did that, and it is the fix for the reported case. But it only closes the timing window we happened to observe. So the larger part of this change is not the fix — it is making the next one visible.
The fix
A bounded refresh on the success path, so a late entitlement is not read as a failure. Purchase reporting moved out of the paywall widget and into the app-level subscription provider, where the defect is absent by construction rather than prevented by a lifecycle check. And purchase_start / purchase_result events on every exit path — including the ones that never reach the store:
Future<bool> purchase(Package package, {String source = 'paywall_screen'}) async {
final plan = package.packageType == PackageType.monthly ? 'monthly' : 'yearly';
analytics.trackPurchaseStart(plan: plan, source: source);
// Checked before the store call so this exit is distinguishable from a
// user cancellation, which also surfaces as a plain `false`.
if (!_service.isInitialized) {
analytics.trackPurchaseResult(
status: 'failed', errorCode: 'NOT_INITIALIZED', store: _storeName);
return false;
}
...
}
And a purchase call that reports why it ended rather than returning a bare boolean:
/// Prefer [purchaseWithOutcome] when the caller has to report what happened:
/// a `false` collapses "the parent dismissed the sheet" and "the store
/// completed but no entitlement arrived" into one value — and those two are
/// exactly the pair this investigation could not separate.
Future<bool> purchase(Package package) async =>
await purchaseWithOutcome(package) == PurchaseOutcome.success;
The first version of the logging change filed three different outcomes as cancelled, one of which was "the store returned normally but no entitlement arrived" — the shape where money may have moved. It rebuilt, inside the fix, the exact blind spot the fix existed to remove. The logging review caught it. ENTITLEMENT_MISSING is now its own signal, and always a failed.
error_code is a closed set, validated at the sink rather than trusted from the caller. One future PurchaseException(e.toString()) would otherwise push a raw store payload into an attribution pipeline.
Before and after
We do not yet have a post-fix purchase to point at; a real one is required to close this out, and the check has to first establish the transaction was an initial purchase — renewals and offer codes correctly produce no event. What we can measure is the observability, and the interface:
| Before | After | |
|---|---|---|
| Events on a successful purchase | 1 | 3 |
| Events on a cancelled purchase | 0 | 2 |
| Events on a failed purchase | 0 | 2, with a coded reason |
| Distinguishable purchase outcomes | 2 (true / false) |
6 |
| Unit tests on the purchase path | 0 | 14 |
The design gate then found something we were not looking for. The purchase failure message rendered last in the paywall column, at 1019–1299dp on screens 640–915dp tall — off-screen in 30 of 30 measured cells (three screen sizes × two text scales × five locales). A parent whose payment failed watched the button re-enable and saw nothing else. Moving the banner directly above the button fixed that and pushed the button itself off-screen in 30 of 30. Both changes were needed; either alone made it worse.
Mutation testing ran 20 mutants and found 5 alive. Three were the same not-initialized path, where two guards were hiding each other's absence: mutate one, and the other still passes. Two new tests pin them independently.
What we learned
- A success path and an error path that disagree about which state to trust is a defect, even while both appear to work. Ours diverged long before it produced a symptom.
- If two outcomes produce the same observation, you have monitoring for neither. Silence meant "nobody bought" and "we lost a purchase" equally well. The event that mattered most turned out to be the one on the attempt, not the one on the sale.
- A fix to an observability gap can quietly reintroduce it. Collapsing distinct outcomes into a friendly bucket like
cancelledis how, and it passes review unless someone asks what each value would have shown on the day of the incident. - A dependency default is a decision you did not make. Writing it down bought visibility even though we have not yet pinned it.
What's next
End-to-end coverage is blocked rather than done, and we wrote down why instead of waiving it: our integration harness never boots the app entry point and there is no isolated backend, so obeying our own mandatory build rule guarantees a test purchase hits production. Eleven device cases are specified in the interim, including the first written procedure we have for reproducing a late entitlement on purpose.
The StoreKit version is still delegated, not pinned — pinning it needs an in-app purchase key configured with our billing provider first, and getting that wrong fails purchases for everyone, so it ships alone. And one path still sits behind a mounted check; only the analytics moved out of the widget. That one is marked partially done, not done.
Try Tempy
Tempy is a calm, offline-first fever log for parents — built so it survives 3 AM.
Frequently Asked Questions
What caused the missing subscription events in Tempy's analytics?
The missing events were caused by a timing issue where the success path trusted a stale snapshot of customer info, leading to completed purchases being misclassified as failures. Additionally, purchase recording was tied to a UI widget lifecycle, and the system lacked distinct events for different purchase outcomes.
How did Tempy fix the issue of lost purchase events?
Tempy implemented a bounded refresh on the success path to ensure late entitlements are recognized, moved purchase reporting out of the UI widget into the app-level subscription provider, and added detailed analytics events for every purchase outcome, including cancellations and failures with specific error codes.
Why was it difficult to distinguish between declined and lost purchases before the fix?
Previously, there was only one event when a subscription started, and no event when the buy button was tapped. This meant that user cancellations and lost purchase records both produced no analytics event, making it impossible to differentiate between the two scenarios.
What lessons did Tempy learn from this purchase tracking incident?
Tempy learned that inconsistent state handling between success and error paths can cause silent failures, that identical observations for distinct outcomes hinder monitoring, that fixes can unintentionally reintroduce observability gaps, and that relying on dependency defaults without explicit configuration reduces visibility.
What improvements were made to Tempy's purchase analytics after the fix?
After the fix, Tempy increased the number of analytics events per purchase, distinguished six different purchase outcomes instead of two, added unit tests to cover the purchase path, and introduced explicit error codes to improve the clarity and reliability of purchase reporting.
Continue reading

The 57pt layout jump that moved a button mid-tap
A background query finished and Cubist's primary button slid 57pt up the screen. The three-line fix was easy; getting a test to see it was not.

How we cut public-page transfer 57% by deleting our bundle config
A webpack splitChunks block named "bundle optimization" was disabling Next.js per-route splitting and shipping admin-only libraries to 1,078 public pages.

The 3 GB memory leak that wasn't in the heap
Our hosting bill was 87% memory. The heap was flat the whole time — 98% of the growth was native memory V8 cannot see, caused by a 60-second framework default.