In the error-handling post I made the case that retries are your first line of defense against transient failures — the 429, the brief network blip, the momentarily overloaded API. That’s true. But retries come with a sharp edge that almost nobody guards against until it bites them: a retry re-runs the work, and if the work isn’t idempotent, re-running it does the thing twice.
Twice means the card charged twice. The “your order shipped” email sent twice. The CRM contact created twice. The webhook that already succeeded — but whose response got lost on the way back — processed a second time. I consult exclusively on n8n, and duplicate side-effects are the most expensive class of bug I get called in to clean up, precisely because they’re invisible in testing. The happy path never retries, so the double never happens on your machine. It happens in production, at 2 a.m., on someone else’s credit card.
This post is how you keep retries (you need them) without the doubles (you really don’t).
Why your workflow retries more than you think
Most people picture “retry” as the one node setting they turned on. In reality, a production n8n workflow can re-run the same logical event from at least five different directions:
- Retry On Fail on a node — re-executes that node 3–5 times.
- Manual re-execution — you open a failed execution and hit “retry”, or replay an item from a dead-letter store.
- The Error Trigger replay loop — you fix a bug and feed the captured payload back through.
- Provider-side webhook retries — this is the big one. Stripe, GitHub, Shopify, Twilio and most serious webhook senders retry delivery if they don’t get a
2xxfast enough. Your workflow did the work, was slow to answer, the sender gave up waiting and sent the exact same event again. - Queue-mode redelivery — in queue mode, a worker that dies mid-job can cause the job to be picked up again.
The uncomfortable truth underneath all of this: distributed systems deliver at-least-once, not exactly-once. “Exactly once” is not something you receive; it’s something you construct on top of at-least-once delivery, by making the duplicate harmless. That construction has a name, and it’s idempotency.
What idempotency actually means here
An operation is idempotent if doing it twice has the same effect as doing it once. SET balance = 100 is idempotent — run it five times, the balance is 100. ADD 100 to balance is not — run it five times and you’re up $500.
The whole game is converting the second kind into the first kind. Three levers do almost all the work:
- An idempotency key — a stable, unique fingerprint of the event, so you can recognize “I’ve seen this exact one before.”
- A dedup store — somewhere durable to record which keys you’ve already processed.
- Idempotent writes — using upserts and provider-side idempotency support so the downstream system itself rejects the duplicate.
Let’s build each.
Step 1: Derive a stable idempotency key
The key has to be the same every time the same logical event arrives, and different for genuinely different events. The cardinal sin is generating it yourself with something non-deterministic.
// ❌ WRONG — a new key on every execution. This makes every retry look "new".
const key = $now.toMillis() + '-' + Math.random();
Use something intrinsic to the event instead. In order of preference:
- An ID the sender already guarantees is unique — Stripe’s
event.id(evt_...), a Shopify webhook’sX-Shopify-Webhook-Idheader, a message ID. This is the gold standard; the sender has done the work for you. - A natural business key —
order_id, orcustomer_id + invoice_period. - A content hash, only if you have nothing else — a SHA-256 of the meaningful fields.
// ✅ Prefer the sender's own event ID; fall back to a content hash.
import { createHash } from 'crypto';
const body = $input.first().json;
const providerId = body.id ?? body.event_id ?? null;
const idempotencyKey = providerId ?? createHash('sha256')
.update(JSON.stringify({
type: body.type,
order: body.order_id,
amount: body.amount,
}))
.digest('hex');
return [{ json: { ...body, _idempotency_key: idempotencyKey } }];
One subtlety with content hashes: hash only the fields that define the event, never timestamps or delivery metadata the sender adds on each attempt — otherwise the retry hashes differently and your dedup misses it.
Step 2: Check-and-record against a dedup store
Now you need a durable place to remember keys you’ve already handled. n8n’s own execution history is not that place — it gets pruned (EXECUTIONS_DATA_PRUNE), and it isn’t queryable as a dedup index. Use a real store: a Postgres table, a Redis set with a TTL, even an n8n Data Table for low volume.
The naive version has a race condition that matters under concurrency:
// ⚠️ Check-then-act: two copies arriving at once can BOTH read "not seen".
// SELECT 1 FROM processed WHERE key = $key → both get nothing
// ...both then proceed... → duplicate anyway
The fix is to let the database enforce uniqueness atomically instead of checking first. Make the key a primary/unique key and do an insert that no-ops on conflict — the insert itself is the claim:
-- Postgres dedup table
CREATE TABLE processed_events (
idempotency_key TEXT PRIMARY KEY,
workflow TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "Claim" the event. If this inserts a row, you won the race — proceed.
-- If it changes 0 rows, someone already processed it — stop.
INSERT INTO processed_events (idempotency_key, workflow)
VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
In the workflow: run that INSERT, then branch on whether a row came back. Rows returned → first time, do the work. No rows → duplicate, exit quietly (still return 200 to the webhook sender so they stop retrying). This collapses the race because the database, not your workflow, decides the winner — and it does so atomically.
Order matters. Claim the key before the side-effect when you can tolerate a rare “claimed but not done” gap, and reconcile from the dead-letter store. For money movement specifically, prefer Step 3 — push idempotency all the way down to the provider.
Step 3: Make the write itself idempotent (upserts + provider keys)
The strongest pattern doesn’t rely on your dedup store being perfect — it makes the downstream operation refuse to duplicate on its own.
Upsert instead of insert. If you’re writing to your own database, INSERT ... ON CONFLICT DO UPDATE (or the platform’s “update or create”) means a replay overwrites rather than duplicates:
INSERT INTO customers (email, name, plan)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name, plan = EXCLUDED.plan;
Use the provider’s idempotency support. This is the part people miss in n8n. Many APIs accept an idempotency key on the request itself and de-duplicate server-side — a retry with the same key returns the original result instead of performing the action again. Stripe is the canonical example: send an Idempotency-Key header on the HTTP Request node and a double-fired “create charge” becomes a single charge, guaranteed by Stripe, not by you.
// HTTP Request node → Headers
Idempotency-Key: {{ $json._idempotency_key }}
PayPal (PayPal-Request-Id), Square, Adyen and others have equivalents. If a node is moving money or creating something costly, check whether the API has an idempotency mechanism and use it — it’s the only layer that survives even your own bugs.
Step 4: Bound your retries so they can’t loop forever
Idempotency makes a duplicate harmless; it doesn’t make an infinite retry loop harmless. Pair it with limits:
- Cap attempts — 3–5, then route to the dead-letter store. A permanent
400will be permanent on attempt fifty too. - Back off exponentially with jitter — so simultaneous retries don’t synchronize into a thundering herd (code for this is in the error-handling post).
- Classify first — only retry the transient (
429,408,5xx). Retrying the un-retryable is just a faster way to reach the dead-letter store.
Idempotency and bounded retries are two halves of one mechanism: retries give you resilience, idempotency makes that resilience safe.
Anti-patterns I see constantly
- Retries on, idempotency nowhere. The single most common cause of duplicate charges and double emails. If a node has Retry On Fail enabled and a side-effect, it needs an idempotency story.
- Generating the key with
$noworMath.random(). Every attempt looks new, so the dedup never fires. The key must be derived from the event, deterministically. - Check-then-act dedup under concurrency. A
SELECTthenINSERTlets two simultaneous copies both pass the check. Let a unique constraint +ON CONFLICTdecide atomically. - Trusting n8n execution history as the dedup store. It’s pruned and not indexed for lookup. Dedup lives in a real, durable store outside n8n.
- Returning non-2xx after you’ve already done the work. The sender retries a job that actually succeeded. If you’ve processed it, answer
200even on the duplicate path. - Hashing the whole payload including delivery metadata. Per-attempt timestamps change the hash, so retries don’t match. Hash only the defining fields.
The pattern behind all of it
You can’t buy exactly-once delivery — it doesn’t exist on the open internet. What you can do is accept at-least-once and make the second delivery a no-op: a deterministic key, an atomic claim against a durable store, an upsert or a provider idempotency header on the write, and bounded, classified retries around the whole thing. Do that, and retries stop being a liability and go back to being what they’re supposed to be — the reason your workflow survives a bad afternoon instead of paging you through it.
Idempotency is one of the six dimensions in the free n8n Production-Readiness Checklist — alongside error handling, secrets, audit trails, dead-letter queues, and monitoring — each with the red flags and quick fixes to self-assess where your workflows stand.
What to do next
If you want a trained eye on it, the noorflows Pre-Flight Review scores your existing workflows against all six production-readiness dimensions and returns a prioritized, node-level fix list — including every place a retry can currently fire a side-effect twice, ordered by blast radius.
Already know a workflow is double-firing and need it fixed properly? Email me with a rough description of your setup and I’ll tell you honestly what it needs.