Build1 distinct publisher3 min readUpdated
A Node and Postgres webhook guide gets the storage choice right and the ordering wrong: SELECT then INSERT leaves a race that answers a concurrent duplicate with a 500.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
The unique index in that schema is doing real work. The SELECT above it is not the thing doing it. Two deliveries of the same `event_id` arriving together both pass the 300-second freshness test, both pass the constant-time HMAC compare, both find no row, and both call `next()` [4]. Postgres then serialises them at the unique index and the second INSERT raises a violation [3], which the author describes as Postgres handling the lock and dropping the duplicate [8]. It does drop it, and the balance is not credited twice, because the INSERT sits above the business logic [5]. But the loser's exception falls into the handler's generic catch and comes back as HTTP 500 [5], and 500 is the status that tells a sender to come back. The 409 the design wants only shows up on a later attempt, once the winner's row is committed and visible to the lookup [4].
The case made for Postgres over Redis is durability: a cache flush or a container restart during a spike loses processed-event state [2]. That is a configuration argument, and configuration can answer it. The argument that tuning cannot answer is atomicity. When the dedupe key lives in the same database as the balance, one transaction makes the row and the credit succeed or fail as a unit. Two stores mean two commits and a gap between them, and everything that happens in that gap presents later as either a double credit or a payment that evaporated.
Two smaller things in the schema. A UNIQUE column is already backed by an index, so the explicit `idx_processed_webhooks_event_id` is a second index on the same column, and every inserted row pays for both [3][3]. And nothing prunes the table [9], although the freshness check quietly bounds what has to be kept: no request more than 300 seconds out of date reaches the lookup at all [4], so a row older than five minutes can never be matched by a duplicate that got through the first gate [5]. Five minutes of retention is sufficient for the mechanism as written, which is a much smaller storage commitment than an append-only ledger of every event you have ever seen.
The verification script in the piece is sequential: send, get 200, send the same payload again, get 409, then backdate the timestamp and get 401 [7]. That ordering is why the race survives testing. The second request always meets a committed row. Two curl processes started at the same moment would have produced the 500 instead, and the fix is in the same paragraph as the bug: let the INSERT be the check, and translate its failure into the conflict response the sender already knows how to read.
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
The dev.to writeup states that validating signatures is step one but will not protect against a replay attack in which a valid, signed payload gets resent.
The author writes that processed event IDs could be stored in Redis, but a cache flush or a container restart during a high-traffic spike loses state, and recommends PostgreSQL with a unique constraint instead.
The schema creates processed_webhooks with event_id VARCHAR(255) UNIQUE NOT NULL and then adds a separate index, idx_processed_webhooks_event_id, on processed_webhooks(event_id).
The middleware runs three checks before execution: it rejects requests whose timestamp differs from now by more than MAX_AGE_SECONDS = 300 with a 401, compares an HMAC-SHA256 signature using crypto.timingSafeEqual and returns 403 on mismatch, then runs SELECT id FROM processed_webhooks WHERE event_id = $1 and returns 409 'Event already processed' if a row exists.
In the route handler the INSERT into processed_webhooks runs before the business logic (a console.log crediting the user), and any thrown error is caught and answered with HTTP 500 'Processing error'.
The article's prose instructs the reader to log the event inside Postgres as part of the transaction block, but the posted handler contains no BEGIN or COMMIT.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Verifiable code, no external corroboration
The source publishes the complete schema, middleware, and route handler, so the structural findings - SELECT in middleware versus INSERT in handler, missing BEGIN/COMMIT, duplicate index on event_id, catch-all 500 - are checkable directly against quoted code rather than inferred. What is absent is any independent testing, benchmark, or second publisher, and the claim that Postgres 'drops the duplicate' is asserted without a demonstration, so evidence is solid on structure and thin on behaviour under load.
No adoption signal
The cluster contains one tutorial with no release, deployment, usage disclosure, benchmark, or incident data. Nothing indicates whether this pattern is running in any production system, so adoption cannot be scored without inventing facts.
Exactly-once headline over at-most-once code
The framing promises that every webhook payload runs exactly once and that Postgres locks and drops simultaneous duplicates. The published code delivers neither: the read-then-write gap makes a concurrent duplicate surface as HTTP 500, and committing the marker before the business logic makes a post-marker failure permanent, which is at-most-once. The prose also claims a transaction block the code does not contain. The gap is real and specific, but bounded - the storage choice and the constant-time HMAC comparison are genuinely sound - so this is overstatement rather than fabrication.
Community tutorial, no commercial stake
The piece is a self-published first-person developer tutorial on a community platform, promoting no product, vendor, or paid service; the technologies named are open-source defaults. The visible incentive is authorial reach through a confident how-I-did-it framing, which plausibly explains the exactly-once headline outrunning the code, but there is no commercial or funding pressure evident in the material.
High on code reading, low on real-world behaviour
Confidence is high for everything derived from quoted code, because the artefact is fully reproduced and the reasoning about read-then-write races and unique-violation handling follows from it. It is lower overall because there is a single publisher, no adoption evidence, and no runtime observation, so statements about how often these paths fire in practice remain unresolved.
build
1,254 dead mutants, a 100% score, and a payment charged twice1 distinct publisher
build
The optional EntityManager is the bug: moving the transaction boundary into AsyncLocalStorage1 distinct publisher
build
A Timed-Out Reset SMS Is Not A Failed One, And Your Retry Code Probably Disagrees1 distinct publisher
build
Three services you can delete: queue, cache and search in one Postgres1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 22, 2026