Skip to content

The double-charge bug that only ever showed up in production

A rare double-charge complaint traced back to a race condition between two webhook deliveries: reproduced from production logs, fixed, and locked down with a regression test.

Payments today
Every charge beside the webhook deliveries it saw · invoice.payment_due guarded by an advisory lock on the event ID
Find an event Thu, 09/17/2026RN
Charges today1,284One per payment-due event
Second deliveries absorbed77Waited on the lock, found it processed, exited
Duplicate charges0Reports: zero in the 2 quarters since the fix
Concurrent race test100/100Both deliveries fired at once, last CI run

Charges web-2 web-5

Newest first · deliveries in arrival order
TimeInvoiceCustomerAmountEventDeliveriesLockResult
2:14:08 PMINV-51873Harlow & Fenn LLP$3,450.00ev_8RD1C4VH2Mweb-2HeldCharged once
2:13:51 PMINV-51872Quarry Lane Dental$612.50ev_8RD1B9ZK7Pweb-5web-22nd waitedCharged once
2:13:22 PMINV-51869Ostrander Surveying$1,980.00ev_8RD1AA3T0Qweb-5HeldCharged once
2:12:47 PMINV-51866Pellham Drafting Co.$1,240.00ev_8RD19XE5LWweb-2HeldCharged once
2:12:05 PMINV-51864Brightwater Tax Group$845.00ev_8RD18MW2JDweb-2web-52nd waitedCharged once
2:11:39 PMINV-51861Kestrel Architecture$4,200.00ev_8RD17QN6FSweb-5HeldCharged once
2:10:58 PMINV-51858Mendez Family Law$2,375.00ev_8RD16C0YRAweb-2HeldCharged once
2:10:14 PMINV-51855Northfield Bookkeeping$390.00ev_8RD15HT8KBweb-5HeldCharged once
2:09:31 PMINV-51852Alder Point Engineers$5,125.00ev_8RD14ZP1VCweb-2web-52nd waitedCharged once
2:08:46 PMINV-51850Cobb Street Physio$275.00ev_8RD13WL4MXweb-5HeldCharged once
2:08:02 PMINV-51847Linden Wealth Advisors$1,600.00ev_8RD12KF9QEweb-2HeldCharged once

Double-charge reports

Customer reports, not charges
Before the fixRecurring18 months · dismissed twice as a provider glitch
Since the fix0In the 2 quarters since

Guard on invoice.payment_due

Event guards
LockAdvisory, on event ID
Held forThe whole handler
Check + writeOne critical section

The shape of the work

Industry

Professional Services

Duration

3 weeks

Cooperation model

Fixed price

Services
Production diagnosisRoot-cause fixRegression testing
Integrations
HubSpotDocuSignXeroGoogle Workspace
Technologies
Node.jsTypeScriptPostgreSQL advisory locksStructured loggingLog aggregationVitest
Team
1 Project lead2 Frontend engineers1 Backend engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

The problem

What went wrong, and when

01
  1. 01

    The bug never appeared in staging or on-demand testing, only for a small fraction of real customers under real traffic, which meant two prior attempts to fix it had shipped changes that didn't actually address the cause.

    It couldn't be reproduced locally, in staging, or on demand, because the trigger was two webhook deliveries of the same event arriving on different instances within a few milliseconds of each other, a condition only real traffic produces. Both prior fixes had targeted plausible causes and neither had touched the actual one.

    We reconstructed the failure from production webhook logs, found that two near-simultaneous webhook deliveries for the same event could both pass the idempotency check before either had written its result, fixed the race with a proper lock, and wrote a regression test that fires both webhooks concurrently to prove it can't happen again.

Process

Phase by phase

  1. Phase 1: Gather

    Every reported case

    Pulled production logs for every double-charge complaint and looked for the shared pattern.

    • Incident log set
    • Pattern analysis
  2. Phase 2: Reconstruct

    Timing, precisely

    Reconstructed the timing of concurrent webhook deliveries against the idempotency check.

    • Timing reconstruction
    • Reproduction script
  3. Phase 3: Isolate

    The window between check and write

    Isolated the race: both deliveries passed the check before either had written, because the check and the write weren't atomic.

    • Root-cause writeup
  4. Phase 4: Fix and prove

    Lock, then test concurrently

    Fixed the race with a proper lock and added a regression test firing concurrent webhooks.

    • Lock implementation
    • Concurrent regression test
Casebook · duplicate charges
A 3-week diagnosis: nothing was changed until the failure had been reconstructed from evidence
Closed · root cause on day 4 Thu, 09/17/2026RN
1GatherEvery reported caseIncident log set · Pattern analysis
2ReconstructTiming, preciselyTiming reconstruction · Reproduction script
3IsolateBetween check and writeRoot-cause writeup
4Fix and proveLock, then test concurrentlyLock implementation · Concurrent regression test

Double-charge reports, before and since

18 months of intermittent reports · marker positions drawn to shape
Fix 1 shipped · no diagnosisFix 2 shipped · no diagnosis 0 reports18 months · dismissed twice as a provider glitch3 weeks · isolated day 42 quarters since the fix
Customer report Prior fix (2), plausible cause Engagement

Root-cause writeup

Shared with the billing team · 03/31/2026
SymptomA customer charged twice for one invoice. Rare, never reproduced locally, in staging or on demand, and dismissed twice as a billing-provider glitch.EvidenceStructured webhook records aggregated and read as a timeline: two deliveries of one event, 11 ms apart, on web-2 and web-5. Invisible in either request alone.CauseThe idempotency check read the processed table and wrote to it after the work completed. Both deliveries read empty, and both charged.Fix and proofAn advisory lock on the event ID across the whole handler; check and write in one critical section. A concurrent test fires both deliveries against a real database.

Deliverables

3 weeks · fixed price
Incident log setGatherPattern analysisGatherTiming reconstructionReconstructReproduction scriptReconstructRoot-cause writeupIsolateLock implementationFix and proveConcurrent regression testFix and prove
On screen

The casebook: gather, reconstruct, isolate, fix and prove; eighteen months of reports and two undiagnosed fixes, the three-week engagement with the root cause on day four, and zero reports since.

The numbers, before and after

Recurring → zero since fix

Double-charge reports

18 months of intermittent reports → 4 days to isolate

Root cause found

Concurrent webhook race now tested

Regression coverage

The double-charge figure is a count of customer reports, recurring before the fix and zero in the two quarters since. Time to root cause is measured from the engagement starting, against eighteen months of intermittent reports before it. Regression coverage is a statement about the test, which fires both deliveries concurrently against a real database.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

Introduction

The engagement

There had been a handful of customer complaints about being charged twice for the same invoice. Rare, unreproducible locally, and dismissed twice before as a one-off billing-provider glitch.

A billing integration that had produced a handful of double-charge complaints over eighteen months: rare enough to be dismissed as a provider glitch, and dismissed twice on exactly that basis. The engagement was three weeks and started from the position that two previous fixes had shipped without a diagnosis, so the first job was to stop guessing.

Production Diagnosis & Bug Fixing

How it was handled

  1. 01

    Pulled production logs for every reported double-charge to find the common pattern

    Nothing was changed until the failure had been reconstructed from evidence, because two previous fixes had already been spent on plausible causes.

  2. 02

    Reconstructed the timing of concurrent webhook deliveries against the idempotency check

    Read as a timeline, the records showed two deliveries of one event eleven milliseconds apart on different instances, invisible in either request alone.

  3. 03

    Isolated the race condition: two webhooks both passing the check before either wrote its result

    Isolating it took four days of the three weeks, and confirmed that both previous fixes had targeted plausible causes and missed the real one.

  4. 04

    Fixed the race with a proper lock and added a regression test firing concurrent webhooks

    An advisory lock on the event ID now spans the whole handler, and the regression test fires both deliveries concurrently against a real database.

Reproduced from logs

The failure reconstructed from production webhook timing, with nothing left to guesswork.

The bug had no reproduction, so the logs were the evidence. Structured webhook records were aggregated and sorted by provider delivery ID, which showed two deliveries of the same event arriving eleven milliseconds apart on different instances. That interleaving was the whole failure, and it wasn't visible in any single request's log. Only in the two side by side.

What shipped
  • Reconstructed from aggregated production logs, not a repro
  • Two deliveries of one event, eleven milliseconds apart
  • Only visible with both requests correlated side by side
Delivery trace
Aggregated production records, sorted by provider delivery ID · Pellham Drafting Co., INV-40218, 03/11/2026
Open in log search Thu, 09/17/2026RN
event_id = ev_7QK2M9TD4Xsort: provider_delivery_idall instances2 deliveries · 2 instancesΔ 11 ms apart

The two deliveries, laid over one another

First 32 ms of each handler · offsets other than 11 ms illustrative
+0+4+8+12+16+20+24+28+32
web-2dlv_01J8ZK3F7Qreceivedcheck → not seencharge callinsert → +619 ms →
web-5dlv_01J8ZK3F7Rreceivedcheck → not seencharge callinsert → +628 ms →
Both hold a “not seen” answerFrom web-5’s check until web-2 writes its row, both handlers believe the event is new. Each request, read alone, is a clean success.

Structured records, both instances

Mar 11, 2026 · 2:47 PM (UTC−5) · seconds shown
TimeInstanceDeliveryRecordFieldsLevel
07.412web-2dlv_01J8ZK3F7Qwebhook.receivedtype=invoice.payment_due status=acceptedinfo
07.415web-2dlv_01J8ZK3F7Qidempotency.checkprocessed_events → not seeninfo
07.418web-2dlv_01J8ZK3F7Qcharge.createinvoice=INV-40218 amount=$1,240.00info
07.423web-5dlv_01J8ZK3F7Rwebhook.receivedtype=invoice.payment_due status=acceptedinfo
07.426web-5dlv_01J8ZK3F7Ridempotency.checkprocessed_events → not seeninfo
07.429web-5dlv_01J8ZK3F7Rcharge.createinvoice=INV-40218 amount=$1,240.00info
08.031web-2dlv_01J8ZK3F7Qprocessed_events.insertok · response 200info
08.040web-5dlv_01J8ZK3F7Rprocessed_events.inserton conflict do nothing · response 200info
On screen

The two deliveries laid over one another from aggregated production logs: the same event on two instances, eleven milliseconds apart, both holding a “not seen” answer while each logged a clean success.

Event guards
The lock each event takes, how long it holds it, and what a delivery does when the lock is busy
Deployed · handler v2 Thu, 09/17/2026RN

How each event is guarded

PostgreSQL advisory locks
EventLock keyHeld forWhen busy
invoice.payment_dueevent IDWhole handlerWait, then exit if seen
invoice.refund_requestedevent IDWhole handlerWait, then exit if seen
subscription.renewedevent IDWhole handlerWait, then exit if seen
charge.disputedevent IDWhole handlerWait, then exit if seen
payment_method.updatedevent IDWhole handlerWait, then exit if seen
customer.email_changed——Idempotent upsert

Held right now

pg_locks · locktype = advisory · live
KeyEventHolderHeldWaiting
1862047395ev_8RD1C9QM3Tweb-2212 msweb-5Waiting
-730159284ev_8RD1C9RD5Wweb-5148 msNo one
402918377ev_8RD1C9SJ0Nweb-296 msNo one
-1553902816ev_8RD1C9TA6Kweb-541 msweb-2Waiting
977213640ev_8RD1C9V2HPweb-212 msNo one

One critical section

Per delivery, in order
Take lockon event ID Checkprocessed? Charge + writerow inserted Commitlock released
A second delivery blocks at step one, then finds the row written and exits.

handlers/payment-due.ts

v1 → v2
Before · read, work, then write-async function handle(ev) {- const seen = await db.processed.find(ev.id)- if (seen) return ok()- await billing.charge(ev.invoice)- await db.processed.insert(ev.id)- return ok()-} Two deliveries can both read empty before either inserts After · lock, check, work, write+async function handle(ev) {+ return db.tx(async (tx) => { // pg_advisory_xact_lock(hashtext(id))+ await tx.advisoryLock(ev.id)+ const seen = await tx.processed.find(ev.id)+ if (seen) return ok()+ await billing.charge(ev.invoice)+ await tx.processed.insert(ev.id)+ return ok() }) // lock released on commit+}
Lock spans the handlerCheck + write together2nd delivery waits

The real race

On screen

How each event is guarded and what is locked right now: an advisory lock on the event ID held across the whole handler, a second delivery waiting its turn, and the handler diff that put check and write in one critical section.

Two concurrent deliveries both clearing the idempotency check before either wrote its result.

The idempotency check read the processed table and wrote to it after the work completed, leaving a window in which both deliveries read empty and both proceeded. It was replaced with a Postgres advisory lock taken on the event ID for the duration of the handler, so the second delivery blocks, then finds the record written and exits. The check and the write now sit inside one critical section.

What shipped
  • Read-then-write window was the actual defect
  • Advisory lock on the event ID held across the whole handler
  • Check and write moved inside one critical section

A concurrent regression test

A test that fires both webhooks simultaneously, so the bug can't return quietly.

The regression test fires both deliveries concurrently against a real database instead of asserting the lock is called, because a mock proves the code was written, not that the race is closed. It runs a hundred iterations in CI and asserts exactly one side effect each time. It's the kind of test that fails loudly if someone later moves the lock for a good-sounding reason.

What shipped
  • Both webhooks fired concurrently against a real database
  • Asserts exactly one side effect across a hundred iterations
  • Would fail if the lock is later moved or narrowed
Concurrent race test
Fires both deliveries of one event at once against a real database, and counts the side effects
Run now Thu, 09/17/2026RN
Iterations per run100Every CI run, not a sample
Side effects per iterationExactly 1Charges and processed rows, counted
DatabasePostgreSQLA real instance, not a mock
Against the v1 handlerFailsTwo charges on one event

Run #2291 100/100

One square per iteration
Per iterationSeed one event, fire both deliveries together.Charges1 every timeProcessed rows1 every timeSecond deliveryWaited on the lock, exited
One side effect Two side effects

payment-due.race.test.ts

Vitest · real Postgres
it("charges once when both deliveries race", async () => { for (let i = 0; i < 100; i++) { const ev = await seedPaymentDueEvent(db) await Promise.all([ deliver(ev, { instance: "web-2" }), deliver(ev, { instance: "web-5" }), ]) expect(await countCharges(db, ev.id)).toBe(1) expect(await countProcessed(db, ev.id)).toBe(1) } }) // Not: expect(lock).toHaveBeenCalled() — passes against v1

CI runs

Fails loudly if the lock is moved or narrowed
RunBranch · commitTriggerHandler under testIterationsResultWhen
#2291main · 4be91c0Pushv2 · locked100One side effect every iteration09/17 1:52 PM
#2288rn/refund-eventsPull requestv2 · locked100One side effect every iteration09/17 11:08 AM
#2284main · 17d2a6ePushv2 · locked100One side effect every iteration09/16 4:31 PM
#2279main · c03f8b2Nightlyv2 · locked100One side effect every iteration09/16 2:00 AM
#2270verify/pre-fixManualv1 · unlocked100Two charges on one event03/27 10:14 AM
#2269verify/mock-lockManualv1 · lock mocked100Two charges on one event03/27 9:58 AM
On screen

The regression test that fires both deliveries at once against a real database, a hundred iterations per CI run with exactly one side effect each, and the manual runs against the old handler that fail.

Working inside their operation

  1. 01

    A cross-functional team of 3 worked on a fixed price basis over 3 weeks, covering Production diagnosis, Root-cause fix, Regression testing. We ran daily standups with their own lead in the room, and a demo at the end of every sprint. Scope changed twice during the engagement, and both times the change was priced and agreed before work started.

    Three weeks, fixed price, and structured as a diagnosis before any fix: nothing was changed until the failure had been reconstructed from evidence. Production logs were aggregated and read as a timeline instead of a list of errors, and that's the whole difference. The fault is invisible in any single request and obvious with two side by side.

What changed in the runbook

  1. 01

    An idempotency check that isn't atomic with its write isn't an idempotency check.

    The check and the write have to be one critical section. Separated by any amount of work, they describe an intention and enforce nothing.

  2. 02

    The bug was unreproducible until the logs were read as a timeline instead of as errors.

    Errors were the wrong lens: each request logged a clean success, and the fault only exists in the relationship between two of them.

  3. 03

    A regression test that doesn't run the operations concurrently would have passed against the broken code.

    A test asserting the lock is called would have passed against the broken code. Only running the operations concurrently tells the two apart.

One event, two deliveries

11 milliseconds was enough to charge a customer twice

The same pair of deliveries through the old handler and the fixed one. Step through the interleaving and watch the lock, the processed table and the charge count. Switch tabs with the arrow keys once one is focused.

Step 1 of 7

The v1 handler: check the processed table, charge, then write the row once the work is done.

web-2dlv_01J8ZK3F7Q
First delivery · arrives +0 ms
  1. In flight…
Not yet arrived
Postgres
Advisory lock · event ID free
processed_eventsno row
Charges · INV-402180
web-5dlv_01J8ZK3F7R
Second delivery · arrives +11 ms
  1. In flight…
Not yet arrived
+0 ms

The billing provider delivers ev_7QK2M9TD4X twice. Both deliveries land on different instances.

Handled once The race Waiting on the lock Lock11 ms between deliveries is from the production logs; other offsets are illustrative.
Architecture

From a duplicate delivery to exactly one charge

The provider can still deliver an event twice. What changed is everything after the delivery lands: the evidence that found the race, the lock that closes it, and the test that keeps it closed.

  1. 01 · Trigger
    Provider webhook deliveriesTwo deliveries of one event can arrive 11 ms apart on different instances, a condition only real traffic produces.
  2. 02 · Ingest
    Structured logs, aggregatedWebhook records aggregated across instances and sorted by provider delivery ID, so two requests read as one timeline.
  3. 03 · Engine
    Advisory lock on event IDA Postgres advisory lock held for the whole handler; the check and the write sit inside one critical section.
  4. 04 · State
    Processed recordWritten inside the lock, so a second delivery blocks, then finds the record and exits without a side effect.
  5. 05 · Guard
    Concurrent regression testBoth deliveries fired at once against a real database, 100 iterations in CI, exactly one side effect each time.
So no customer is charged twice

Evidence, a real lock & a concurrent guard

Diagnosed before anything changed

Nothing was touched until the failure had been reconstructed from production logs. Two earlier fixes had shipped against plausible causes; this one shipped against the evidence.

One charge per event, enforced

An advisory lock on the event ID spans the whole handler, with the check and the write in one critical section. A second delivery waits, finds the record, and exits.

The race can't return quietly

A test fires both deliveries concurrently against a real database, 100 iterations in CI, asserting exactly one side effect. Moving or narrowing the lock fails it.

Is a bug nobody can reproduce still reaching your customers? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.