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.
Continue reading

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.

The root loading.tsx that turned every redirect into a 200
A root app/loading.tsx wraps every Next 15 route in a Suspense boundary. On force-dynamic pages that commits 200 headers before redirect() can throw — so 497 permanentRedirects returned 200. One-file delete fixed it.

The enum-case bug that labeled every free tool "paid"
Every tool in our directory told Google the same thing about price: it was paid. Free tools too. The cause was a lowercase enum comparison that never matched.