Writing Backend correctness

Forty jobs queued, ten credits spent

Enqueue first and charge later looks orderly. Concurrent admissions leave jobs the ledger never paid for.

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.

PathWhat we needResult
enqueue then debit (concurrent)ledger honest, promises already madequeued 40, spent 10, orphans 30
debit then enqueue (same section)invariantqueued 10, spent 10, orphans 0
serial check then enqueuewrong order, no overlapqueued 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.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact