Most n8n bugs are loud. The dangerous ones are silent, and the worst of them is this: the workflow runs twice and both runs do the same side effect.
You find out weeks later, when a customer got two onboarding emails, an invoice was charged twice, or one lead landed in the CRM three times. By then the execution logs are gone.
Here is why it happens, and the patterns that actually stop it.
Why a workflow runs twice
- Retry On Fail. If a node retries after a timeout, the request may have already succeeded on the other side. The retry sends it again.
- Webhook re-delivery. Many providers (Stripe, Shopify, GitHub, Twilio) resend a webhook if they do not get a fast 2xx. Your workflow fires once per delivery, not once per event.
- Overlapping schedules. A run that takes 6 minutes on a 5-minute schedule means two runs overlap and both see the same rows.
- Manual reruns / partial re-execution. You fix one node and re-run from there, but the earlier nodes already did their work.
- Multiple workers in queue mode picking up the same job boundary.
The one rule
Idempotency means: running the workflow twice has the same effect as running it once.
You get it by deriving a stable key from the input, and refusing to do the side effect twice for the same key.
The key must be a property of the event, not of the execution. Good sources:
- an event / order / invoice / message id already in the payload
- email + campaign id
- a hash of the meaningful fields (when the event has no id)
Do NOT build the key from things that change on every run: $execution.id, $now, a random uuid, or the item index. Those make every run look brand new, so nothing is ever deduped.
Pattern 1: check, act, then record the key
Keep processed keys somewhere durable (Postgres, Redis, Airtable, Google Sheets, a Processed table).
Flow:
- Build key (Code node).
- Look the key up in the store.
- IF found → stop here (or log a duplicate and exit).
- Otherwise do the side effect.
- Write the key to the store.
Code node to build the key:
return $input.all().map((item) => {
const d = item.json;
const raw = [d.event_id, d.order_id, d.email].filter(Boolean).join('|');
return { json: { ...d, idem_key: raw.toLowerCase() } };
});
Pattern 2: claim first, then commit
Pattern 1 has a gap: if the side effect succeeds but the workflow dies before the key is written, the retry does it again.
Fix: write the key first with status = pending, then do the side effect, then update status = done.
On a retry:
- key missing → proceed
- key = done → skip
- key = pending → the previous run died mid-flight. Look the effect up on the provider by your key instead of blindly resending.
This single state field turns a silent double-send into a recoverable check.
Pattern 3: let the provider dedupe
Many APIs accept an idempotency key so they do the deduping:
- send the header Idempotency-Key: on the HTTP Request node (Stripe and many others support this)
- or use an upsert / unique constraint instead of a plain insert
For databases, INSERT … ON CONFLICT DO NOTHING on a unique idem_key column is usually enough.
This is the safest option, because the dedupe happens on the side that actually knows the truth.
The 2-minute test
Pick one workflow with a side effect. Send the same input twice (re-run the last execution, or replay the trigger). If the recipient sees two messages, you do not have idempotency yet.
Common mistakes
- The key contains $now or $execution.id, so every run looks new.
- Keys are stored only in workflow static data or memory, and are lost on restart.
- Retry On Fail is assumed to be safe for non-GET requests. It is not.
- The key is written after the side effect with no pending state, so the crash window stays open.
Which side effect in your workflows is still not idempotent? For me the scary one is always whatever sends money or email.