Reserve, then commit: why a crashed tab does not cost you a credit
Build notes on reserve-then-commit metering: how a credit balance stays untouched until a recap actually reaches the user, why commit is idempotent, and what the expiry cron is really for.
The failure mode this pattern exists to kill
The naive way to meter a paid unit of work is to decrement the balance when the work starts. It is one write, it is obvious, and it is wrong in a way that only shows up in support tickets. Between the decrement and the delivery of the thing the user paid for, there is a gap: a parse that blows up on a malformed file, a browser tab that gets killed by the OS, a phone that locks and evicts the page, a user who closes the window because they got a call. In every one of those cases the balance is already down and the user has nothing.
You can patch this with refunds. Refunds are a compensating transaction, which means you now own a second code path that has to run correctly at exactly the moment your first code path failed. That is the worst possible time to depend on your own reliability. The alternative is to never take the money in the first place until you know delivery happened.
Reserve-then-commit inverts the order. Starting the work creates a reservation, which is a row with a TTL, not a mutation of the balance. The balance itself only moves at commit, and commit only fires once the user has demonstrably received the thing. There is no compensating transaction because there is nothing to compensate.
Availability is a computed value, not a stored one
The single most important consequence of this design is that the number you show the user is not the number you store. We store a balance. What the user can actually spend right now is balance minus the sum of active, unexpired reservations. Availability is derived at read time, every time.
This is what lets the pattern be safe without being paranoid. A user with three credits who opens two recaps in two tabs sees one credit available, because two reservations are live. Neither of those tabs has decremented anything. If both recaps get read through to completion, the balance drops to one and the reservations are gone. If one tab dies, its reservation simply ages out and availability rises back on its own.
The trap for people implementing this is treating availability as something to cache or denormalize. The moment you materialize it into a column you have reintroduced the exact class of bug you were avoiding, because now the derived value can drift from the reservation table and you need reconciliation logic to fix the drift. Keep the subtraction in the query. It is cheap, and it is always right.
What a crash actually does to the ledger
Nothing. That is the whole point, and it is worth saying plainly because users assume the opposite. When a tab crashes mid-recap, no rollback runs, no refund is issued, no error handler needs to have fired. The stored balance was never touched, so there is no state to restore. The reservation sits there until its TTL passes and then stops counting against availability.
This is a meaningfully different guarantee from "we will refund you." A refund promise is only as good as the code that detects the failure. This is a guarantee that holds even if every piece of our error handling is broken, because the safe outcome is the default outcome and the spend is the thing that requires an explicit, successful action.
It also means the correctness of the system does not depend on the client being honest or even reachable. A client that vanishes cannot leave the ledger in a bad state, because a vanished client is indistinguishable from a client that simply never committed.
Idempotent commit, and picking the moment that counts
Commit is the one operation that moves money, so it is the one operation that must be safe to call twice. Retries happen: flaky networks, double-fired events, a user who refreshes. Our commit is idempotent, meaning the second call for the same reservation observes that the spend already happened and returns the same result rather than decrementing again. If you build this yourself, the reservation identifier is your natural idempotency key. You already have it, it is already unique per unit of work, and it is already scoped to the right user.
The other half of the decision is where in the flow commit fires. Too early and you are back to charging for undelivered work. Too late and you are giving away the product. We commit when a user reaches the third slide of a recap. That is far enough in that the parse succeeded, the render succeeded, and real content is on screen; it is not the last slide, so we are not making the charge contingent on the user finishing something they may simply stop reading.
There is no universal right answer to that threshold, but there is a useful test: pick the earliest moment at which a reasonable user would agree they got what they paid for. If you cannot name that moment in one sentence, your product boundary is probably fuzzy, not your metering.
The expiry cron is bookkeeping, not the safety net
We run a daily job that clears out reservations whose TTL has passed and returns the credits they were holding. It is easy to look at that job and conclude it is what protects the user. It is not, and the distinction matters if you are copying the design.
The protection comes from availability being computed as balance minus active reservations. "Active" already means unexpired, so an expired reservation stops affecting the number the moment its TTL lapses, whether or not any job has run. If the cron were disabled for a week, users would still see correct availability the entire time. The rows would just accumulate.
So the cron exists to keep the reservation table from growing without bound and to make the stored state match the computed state for anyone reading the database directly. Those are real reasons to have it. They are operational hygiene, not correctness. Systems get built badly when a background job quietly becomes load-bearing without anyone deciding it should be, and the cheapest way to avoid that is to be able to answer, for every scheduled job you run, what breaks if it does not run tonight. Here the answer is: table size, and nothing a user can see.
Where the pattern fits, and where it does not
Reserve-then-commit earns its complexity when three things are true: the unit of work has a real chance of failing after it starts, the failure is not always observable from the server, and the user would reasonably be angry about being charged for it. A chat recap hits all three. Parsing a WhatsApp export is a nontrivial operation on a file we did not create, rendering happens in a client we do not control, and the whole value of the product arrives at the end rather than the beginning.
It is overkill when the work is atomic and fast enough that failure after the decrement is essentially impossible, or when the unit is so cheap that a wrong charge is not worth a second table. A per-API-call meter on a 40ms endpoint does not need reservations. A long-running generation that a user watches in a browser does.
The tell that you need it is the presence of a refund path in your metering code. If you have written logic whose job is to put credits back after something went wrong, you have already accepted that spend and delivery are separate events. Reserve-then-commit is just the version of that acceptance where the failure case requires no code at all.
Frequently asked questions
If my tab crashes while a recap is loading, do I lose a credit?+
No. Starting a recap creates a reservation, not a deduction. The stored balance is only decremented at commit, so a crashed tab, a closed browser, or a failed parse leaves it untouched. The reservation expires on its own and the credit becomes available again.
At what point is a credit actually spent?+
When you reach the third slide of a recap. By then the parse and the render have both succeeded and real content is on screen, but the charge does not depend on you finishing the whole thing.
Why is availability different from my balance?+
Availability is computed as the stored balance minus any active, unexpired reservations. If you have a recap open in another tab, that reservation is holding a credit even though nothing has been deducted yet.
What happens if the daily expiry job does not run?+
Nothing a user would notice. Expired reservations stop counting against availability the moment their TTL lapses, independently of the job. The cron exists to release the held credits in stored state and keep the reservation table from growing indefinitely, which is bookkeeping rather than the protection itself.
Can a double-fired commit charge me twice?+
No. Commit is idempotent: a repeated call for the same reservation recognises that the spend already happened and returns the same result instead of decrementing again.