A job is already on the queue when the debit runs. The worker can start. The charge is supposed to catch up. Concurrent admits leave unpaid jobs sitting there while the ledger still looks honest.
The wrong model is checkout-then-pay. The queue is not a cart. Accepting the job is already a promise that spend will happen.
Invariant: queued == spent && spent <= budget. The remainder that matters is orphans: queued − spent.
Why a queue slot is already a charge
Two books. The queue is a promise: work will run. The ledger is permission: spend is allowed. They can disagree.
A single counter that overshoots is a different bug. Here the debit can be honest and the queue can still hold unpaid work. Every push is a commitment. Charge after that and you have promised more than you reserved.
The tempting line is warm the worker first, charge when you know it started. Under overlap, “started” is already too late.
Stranded credits (spent − queued) are debit-then-fail-to-enqueue. This lab never charges the extras, so that remainder stays 0.
Lab: honest debit after enqueue is still too late
I ran node lab/debit-before-enqueue.mjs in this repo on 13 August 2026. Budget 10, 40 concurrent workers, 8 ms between enqueue and debit (stand-in for a hop). The debit itself is serialized. The race is the order, not a sloppy increment.
| Path | What we need | Result |
|---|---|---|
| enqueue then debit (concurrent) | ledger honest, promises already made | queued 40, spent 10, orphans 30 |
| debit then enqueue (same section) | invariant | queued 10, spent 10, orphans 0 |
| serial check then enqueue | wrong order, no overlap | queued 10, spent 10, orphans 0 |
The interesting failure is not overspend. Path A held spent === 10. Thirty jobs are still in the queue with an honest ledger. Serial also lands on 10/10 — a one-at-a-time suite would bless the wrong order. One beat, not the spine.
{
"budget": 10,
"workers": 40,
"windowMs": 8,
"enqueueThenDebit": {
"queued": 40,
"spent": 10,
"orphans": 30,
"stranded": 0
},
"debitThenEnqueue": {
"queued": 10,
"spent": 10,
"orphans": 0,
"stranded": 0
},
"serialCheckThenEnqueue": {
"queued": 10,
"spent": 10,
"orphans": 0,
"stranded": 0
},
"allPassed": true
}
The bug in miniature:
queue.push({ id: queue.length });
await sleep(WINDOW_MS);
return ledger.debit();
Every worker pushes before anyone’s debit returns false. The ledger then honestly refuses 30. Those 30 are already promised.
Reservation and promise share a critical section
Debit (or reserve) in the same critical section that may enqueue. If debit fails, nothing is in the queue.
const ok = await ledger.debit();
if (!ok) return false;
queue.push({ id: queue.length });
The lab wraps that in a promise chain so concurrent admits still run one at a time in this process. It is not a distributed lock. It is enough to show the race is the split between push and debit, not “JavaScript can’t count.” Across processes the lock has to live where the ledger lives.
What this does not solve
Rejected: warm the worker, charge later. That is Path A with nicer comments.
This does not replace idempotent workers, refund-on-failure, or a lock that spans machines. If the real spend is the call — an API hit, a token, a billed second — reserve at the call, not at the queue. Enqueue-time debit only covers enqueue-time cost.
Reservation before promise. The thirty orphans are the bill, not a reminder to debit first.