# What You’ll Learn#
Duplicates in automation are rarely a single bug. They’re usually an emergent behavior from retries, concurrent runs, and non-idempotent side effects like sending emails, charging cards, or creating CRM deals.
This guide explains how duplicates happen in real n8n setups, then gives production-ready patterns to make workflows idempotent under concurrency using dedupe keys, database locks, upserts, and the outbox pattern.
You’ll also get sample node setups and a final checklist you can use before going live.
# Why duplicates happen in n8n#
The fastest way to build reliability is to assume duplicates will happen and design for it. Distributed systems retry. Webhooks resend. Queues deliver at-least-once. Even “single-threaded” assumptions break when you scale n8n horizontally.
Below are the most common sources of duplicates, with concrete scenarios.
1) Retries at the workflow level and node level#
If a workflow fails after performing a side effect, a retry can repeat that side effect.
Example: you call a payment API, it succeeds, then the workflow crashes on a later node. On retry, you call the payment API again unless you store and check an idempotency key.
If you already use retries and alerting, keep going, but add idempotency. For patterns on safe retries and alarms, see n8n error handling, retries, and alerting.
2) Duplicate webhook deliveries#
Webhook providers commonly retry on timeout, 5xx responses, or connection errors. Many also retry when the response takes too long.
Typical behavior:
- provider sends webhook
- your workflow does 10 seconds of work
- provider timeout is 3 seconds
- provider retries the same webhook
- you now have two n8n executions running the same event
If you’re building webhook flows, see n8n webhook tutorial for endpoint setup and verification, then add dedupe keys and locks from this guide.
3) Parallel runs due to concurrency settings and horizontal scaling#
n8n can run multiple executions in parallel. That is good for throughput and bad for correctness if you depend on “only one at a time” behavior.
Parallel duplicates usually occur when:
- the same logical entity is processed in multiple executions at once
- your workflow reads state, computes a decision, then writes state
- two executions do the read before either writes
This creates race conditions like double-sending, double-updating, or violating business rules like “only one active subscription”.
4) Polling and data sync overlaps#
If you poll for changes on a schedule and the polling windows overlap, the same record can be pulled twice.
Example:
- you poll every 1 minute
- one run takes 90 seconds
- next run starts before the previous ends
- both runs fetch the same “last 5 minutes” of changes
- duplicates happen unless you dedupe
For deeper sync patterns, pagination, and dedup strategies, see n8n data sync, CDC, pagination, and deduplication.
ℹ️ Note: “Exactly-once” processing is rarely achievable end-to-end. Your goal is “effectively once” side effects using idempotency keys plus durable state.
# The core concept: separate events from side effects#
To make a workflow idempotent, you need a stable way to answer:
- Have I already processed this event
- Which side effects did I already perform
- Is a parallel execution currently processing it
That means you need:
- 1A deterministic dedupe key per logical event
- 2Durable storage for processing state, usually a database
- 3Idempotent writes, typically via upserts or idempotency headers
- 4Optional locks when concurrency must be serialized
# Choosing a dedupe key that actually works#
A dedupe key is only useful if it is stable and unique for the logical event.
Good sources for dedupe keys#
| Source | Example | Pros | Cons |
|---|---|---|---|
| Provider event ID | stripe_event_id | Usually unique and stable | Not always present |
| Composite key | shop_id + order_id + status | Works even without event IDs | Must define carefully |
| Content hash | SHA256 of canonical payload | Works for arbitrary payloads | Hash must be canonicalized |
| Time-window key | device_id + minute_bucket | Useful for rate limiting | Risks collisions |
Rule of thumb#
- For webhooks, prefer provider event IDs.
- For data sync, prefer primary keys plus a version marker like updated timestamp.
- For internal triggers, create and persist a UUID at the source.
💡 Tip: If you can’t find a reliable event ID, create one by hashing a canonical subset of fields that define “same event”, then store that hash in your DB with a unique constraint.
# Pattern 1: Dedupe table with a unique constraint#
This is the simplest, most effective pattern for “don’t run side effects twice”.
Data model#
Create a table where the dedupe key is unique.
| Column | Type | Notes |
|---|---|---|
dedupe_key | text | Unique index |
status | text | processing, done, failed |
first_seen_at | timestamp | For debugging and cleanup |
last_seen_at | timestamp | Track retries |
result_ref | text nullable | Optional, store external ID like invoice ID |
How it works#
- 1On workflow start, attempt to insert
dedupe_key. - 2If insert succeeds, you “own” processing and can continue.
- 3If insert fails due to uniqueness, stop or short-circuit to “already processed”.
Sample n8n node setup#
Use PostgreSQL, MySQL, or any DB that supports unique constraints.
Step A: Compute the dedupe key
Use a Function node.
const payload = $json;
// Prefer a provider event id if present
const eventId = payload.id || payload.event_id;
// Fallback: stable composite key
const composite = [
payload.account_id,
payload.object_type,
payload.object_id,
payload.action,
payload.updated_at,
].filter(Boolean).join(':');
const dedupeKey = eventId || composite;
return [{ dedupeKey }];Step B: Insert the key
Use a Postgres node with an INSERT that does nothing on conflict.
INSERT INTO workflow_dedupe (dedupe_key, status, first_seen_at, last_seen_at)
VALUES ($1, 'processing', NOW(), NOW())
ON CONFLICT (dedupe_key) DO UPDATE
SET last_seen_at = NOW()
RETURNING status;Then branch:
- If the row was newly inserted, continue.
- If it already existed and status is
done, stop. - If status is
processing, decide whether to wait, stop, or treat as in-flight.
⚠️ Warning: A plain “lookup then insert” is not safe under concurrency. Two parallel executions can both see “not found” and both insert. Always use a single atomic insert with a unique constraint.
Step C: Mark done
At the end of the workflow, update status.
UPDATE workflow_dedupe
SET status = 'done', last_seen_at = NOW()
WHERE dedupe_key = $1;When this pattern is enough#
Use it when:
- you mainly want to prevent duplicate external calls
- side effects can be safely skipped if already done
- you can tolerate “already processing” being dropped or handled manually
# Pattern 2: Idempotent upserts for all writes#
Many duplicates become harmless if every write is an upsert keyed by a stable ID.
Upsert examples#
- Creating a CRM contact should be “create or update by email”
- Syncing an order should be “upsert by order_id”
- Writing to your own DB should be “insert on conflict update”
Example: Upsert in Postgres node#
INSERT INTO orders (order_id, status, total_cents, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
total_cents = EXCLUDED.total_cents,
updated_at = NOW();Example: Upsert-like behavior in APIs#
Many SaaS APIs support “update if exists” by natural key:
- Contacts by email
- Users by external ID
- Products by SKU
If the API does not support upsert, you often implement a “search then create” flow, but that can race under concurrency. In that case:
- prefer a provider-side idempotency key header if available
- or add a DB lock around the search-create sequence
# Pattern 3: Provider idempotency keys for external APIs#
Some APIs provide a first-class idempotency mechanism. Stripe is the common example, but many payment, shipping, and invoicing providers have similar headers.
How to use in n8n#
- Compute a stable idempotency key per logical operation.
- Send it as a header in the HTTP Request node.
Example HTTP headers in the HTTP Request node:
Idempotency-Key:your_dedupe_key
If your workflow retries the same request, the provider returns the same result instead of creating a second charge or a second shipment.
What to store#
Even with provider idempotency keys, store the mapping in your DB:
- dedupe key
- provider response ID
- status
This helps debugging and allows you to reconstruct state without querying the provider.
# Pattern 4: Database locking to serialize critical sections#
Locks are for cases where “two runs at the same time” is unacceptable, even if you dedupe. Typical examples:
- decrementing inventory
- allocating sequential invoice numbers
- enforcing “only one active subscription” transitions
- sending a single notification per account per day
Two types of locking you can use#
| Lock type | How | Best for | Risk |
|---|---|---|---|
| Advisory lock | pg_advisory_lock | Per-entity serialization | If you forget to unlock, locks can linger until session ends |
| Row lock | SELECT ... FOR UPDATE | Strong consistency around a record | Can cause contention or deadlocks if misused |
Example: Postgres advisory lock in n8n#
Use a Postgres node before the critical section:
SELECT pg_try_advisory_lock(hashtext($1)) AS locked;If locked is false, you can:
- stop and rely on retry
- wait and retry after a delay
- return 202 and let the sender retry if it’s a webhook
After the critical section, release:
SELECT pg_advisory_unlock(hashtext($1)) AS unlocked;Use a lock key like:
account_idorder_idinvoice_series_id
🎯 Key Takeaway: Use locks to protect shared state transitions, not as a default anti-duplicate mechanism. Dedupe keys plus upserts should handle most workflows with lower operational risk.
# Pattern 5: Outbox pattern for reliable side effects#
The outbox pattern is the most robust approach when you need to update your DB and call an external API, and you cannot afford them to get out of sync.
The problem it solves#
If you do:
- 1update DB
- 2call external API
and step 2 fails after step 1 succeeded, retries may:
- call the external API twice
- or leave DB and external system inconsistent
If you do it in the opposite order, you can get the opposite inconsistency.
The outbox solution#
- 1In a single DB transaction, write:
- your state change
- an “outbox event” row describing the external call to do
- 2A separate workflow processes outbox rows:
- sends external requests
- marks outbox row as sent
This converts unreliable external calls into a durable queue you control.
Minimal outbox table#
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
event_type | text | Like invoice.created |
payload | json | Request body or reference |
dedupe_key | text | Unique, prevents double-send |
status | text | pending, sent, failed |
created_at | timestamp | Operational visibility |
Workflow design in n8n#
- Workflow A: receives webhook, validates, writes DB and outbox row, returns quickly.
- Workflow B: cron-triggered or queue-triggered, processes pending outbox rows, does external calls idempotently, marks sent.
This is especially effective for webhook senders with tight timeouts because Workflow A can respond in under 1 second.
# Sample workflow blueprint: webhook to invoice creation without duplicates#
This is a practical design that covers retries, duplicate webhooks, and concurrency.
Step 1: Webhook trigger#
Use Webhook node. Return response early after you have safely persisted the event.
If you need signature verification, do it before persisting.
Step 2: Generate a dedupe key#
Function node:
const body = $json.body || $json;
// Example: provider event id plus type
const dedupeKey = [body.event_id, body.type].filter(Boolean).join(':');
return [{ dedupeKey, body }];Step 3: Atomic dedupe insert#
Postgres node insert into workflow_dedupe.
If duplicate:
- return HTTP 200 with “already processed” to stop provider retries
- or return 202 if you prefer provider retry semantics
Step 4: Write an outbox row#
Postgres node:
INSERT INTO outbox (id, event_type, payload, dedupe_key, status, created_at)
VALUES (gen_random_uuid(), 'invoice.create', $1, $2, 'pending', NOW())
ON CONFLICT (dedupe_key) DO NOTHING;Store minimal payload:
- internal customer id
- amount
- currency
- invoice reference Avoid storing secrets.
Step 5: Respond to webhook#
Webhook Response node:
- status 200
- body “accepted”
Step 6: Outbox processor workflow#
Trigger: Cron every minute or a queue-like trigger if you have one.
Steps:
- 1Select pending outbox rows with a limit, for example 50.
- 2For each row, acquire a lock by
dedupe_key. - 3Send HTTP Request to invoicing provider with idempotency header set to
dedupe_key. - 4Mark row
sentwith provider response id. - 5Release lock.
# Concurrency patterns in n8n node terms#
n8n users often ask “Which node do I use for locking”. The answer is: use your database as the concurrency control plane.
Common node combinations#
| Goal | Nodes | Notes |
|---|---|---|
| Deduplicate events | Function plus DB Insert | Unique constraint is the guard |
| Serialize per entity | DB lock query plus If | Advisory lock or row lock |
| Idempotent external calls | HTTP Request plus idempotency header | Store response ID |
| Safe sync writes | DB upsert | Avoid read-then-write |
| Reliable side effects | DB transaction plus outbox workflow | Durable queue |
# Handling “processing” state and stuck executions#
A dedupe row with status = processing can remain if an execution crashes.
Handle it explicitly:
- store
started_atandupdated_at - treat “processing older than 15 minutes” as stale
- allow a new execution to take over by flipping status back to
processingif stale
A safe takeover usually needs a lock or a compare-and-swap update.
Example takeover update:
UPDATE workflow_dedupe
SET status = 'processing', last_seen_at = NOW()
WHERE dedupe_key = $1
AND status = 'processing'
AND last_seen_at less than NOW() - INTERVAL '15 minutes';Follow that with a check of affected rows to decide whether you now own processing.
# Production safety checklist for idempotent n8n workflows#
Use this as a go-live gate for any workflow that triggers side effects.
Event and dedupe#
- Every trigger defines a stable dedupe key.
- Dedupe key is stored in a DB table with a unique constraint.
- Workflow short-circuits on duplicates with a safe response, especially for webhooks.
- Dedupe records include timestamps and status for observability.
Concurrency and locking#
- Critical sections that touch shared state are protected by a lock keyed by entity ID.
- Lock acquisition failure is handled predictably, either retry with backoff or stop.
- Long-running executions do not hold locks longer than necessary.
Side effects and writes#
- All DB writes are upserts or otherwise idempotent.
- External APIs use provider idempotency headers where available.
- External create operations store the provider object ID to prevent re-creation.
Reliability and operations#
- Workflows respond to webhooks quickly after persisting state.
- Retries are enabled only for safe idempotent operations.
- Alerts exist for rising failure rates and repeated retries.
- A dead-letter path exists, such as marking outbox rows
failedwith an error reason.
For alerting patterns that catch repeat failures early, reference n8n error handling, retries, and alerting.
# Key Takeaways#
- Use a stable dedupe key per logical event and enforce uniqueness in a database to stop duplicates under retries and parallel runs.
- Prefer atomic operations like insert-on-conflict and upserts instead of “check then write” flows that race under concurrency.
- Add database locks only around true critical sections, like inventory, balances, or sequential numbering.
- For complex side effects, use the outbox pattern so DB state changes and external calls stay consistent across retries and crashes.
- Treat webhooks as at-least-once delivery, respond quickly after persisting state, and design for duplicate deliveries by default.
- Validate production readiness with a checklist covering dedupe, locking, idempotent writes, retries, and alerting.
# Conclusion#
Idempotency is the difference between an automation that “usually works” and one that survives real production conditions like retries, duplicate webhooks, and horizontal scaling. If you implement a dedupe key with a unique constraint, switch writes to upserts, and reserve locks for critical sections, you eliminate most duplicate side effects without slowing down delivery.
If you want Samioda to review your high-risk workflows, add an outbox architecture, or harden your n8n platform for concurrency at scale, contact us and we’ll help you ship automations that stay correct under load.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Business Automation
All →Document Processing Automation with n8n: OCR, Classification, Extraction, and Routing (Production-Ready Guide for 2026)
Build a production-grade n8n document processing automation pipeline for inbound PDFs and images: OCR, classification, field extraction, validation, human review, audit trails, and routing to CRM and accounting tools.
Automated Reporting with n8n: Build Weekly KPI Digests from GA4, Stripe, and Postgres
A practical guide to automated reporting with n8n: pull weekly KPIs from GA4, Stripe, and Postgres, validate data quality, generate a concise narrative summary, and send it to Slack and email with retries and maintainable structure.
n8n + Supabase/Postgres Automation Patterns: Webhooks, RLS-Safe Writes, and Reliable Sync
A practical guide to n8n Supabase Postgres automation patterns: webhook ingestion, idempotency keys, upserts, RLS-safe writes, and reliable two-way sync for SaaS back-office workflows.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
n8n Error Handling in Production: Retries, Dead-Letter Flows, and Alerting
A practical guide to n8n error handling in production — including retry strategies, idempotency, partial failure patterns, dead-letter flows, and Slack or email alerting you can reuse.
Document Processing Automation with n8n: OCR, Classification, Extraction, and Routing (Production-Ready Guide for 2026)
Build a production-grade n8n document processing automation pipeline for inbound PDFs and images: OCR, classification, field extraction, validation, human review, audit trails, and routing to CRM and accounting tools.
Automated Reporting with n8n: Build Weekly KPI Digests from GA4, Stripe, and Postgres
A practical guide to automated reporting with n8n: pull weekly KPIs from GA4, Stripe, and Postgres, validate data quality, generate a concise narrative summary, and send it to Slack and email with retries and maintainable structure.