This is part of my build-in-public series on running LLM workflows in n8n in production. Everything here is from real templates I sell and support — the mistakes are mine, the fixes are tested.
A workflow sent a payment, then a message, then a record update. One night the provider call timed out — not failed, timed out. n8n did the sensible thing and retried. The retry succeeded. So did the first attempt, it turned out: the timeout was on the way back, not on the way in. The customer was charged twice, messaged twice, and every log said "success," because each execution had, in isolation, succeeded.
Retries fix "not yet" failures. They create duplicate side effects. Any workflow that retries — and any workflow that replays from a dead-letter queue, or resumes from a checkpoint — will eventually run the same side effect twice. If that side effect is sending a message, charging a card, or creating a record, "eventually" is a bug report, not a theory.
The fix is one extra field, carried through the whole workflow: an idempotency key. It identifies the intended operation, not the attempt. The same key on a second attempt must produce the same result as the first — without doing the work again.
The key has to be stable across retries, replays, and restarts. That rules out anything generated inside the attempt: a fresh UUID per run is a new operation every time, which is exactly what you are trying to prevent. The key comes from the job itself: the job id plus the operation name is the usual shape — job-4821:charge, job-4821:notify. Same job, same operation, same key, no matter how many times it runs.
Ordering is the whole mechanism, and it is easy to get backwards. The workflow must look up the key before it performs the side effect, not log it afterwards:
Step 3 is where most hand-rolled versions break. If the workflow charges the card and then crashes before recording the key, the retry sees no key and charges again. The record has to be written as close to the side effect as the system allows, and the window between them is the window where duplicates still live. Where the provider offers a native idempotency key — Stripe is the classic example — use it: the provider's store is atomic in a way your workflow's two-step version is not, and passing your key through means the provider itself dedupes the second call.
The store does not need to be fancy. A table with the key, the operation, the result, and a timestamp is enough. What it must be is durable and shared: not execution-local state, not a variable that dies with the run, and visible to every retry path — the ordinary retry, the DLQ replay, and the checkpoint resume. A key that only one retry path checks is a key that the other paths will ignore.
Two practical details. First, keys need a retention policy. Keep them at least as long as your longest retry and replay window — if a DLQ entry can be replayed a week later, a key that expired after a day will not protect it. Second, a stored result is a decision, not just a cache entry: when a retry returns a stored "already charged" result, that outcome should be visible in the job record, so a human reading the run can tell "skipped as duplicate" from "never ran." A silent no-op looks identical to a skipped step in the logs, and the next person debugging will not know which one happened.
This is a different job from the failure classification in the poison-message piece and the retry budget in the backoff piece. Classification decides whether to retry at all; the retry budget decides how many times. Idempotency decides what the second attempt is allowed to do. All three have to hold: a workflow that retries without idempotency will eventually double-charge; a workflow with idempotency but no classification will retry poison inputs forever, harmlessly but pointlessly.
Testing this means deliberately delivering the same job twice and asserting three things: the side effect happened exactly once, the second run returned the first run's result, and the job record shows the second run as a duplicate skip rather than a fresh success. The test I actually run is boring and specific: one job, two deliveries — one of them forced by killing the run between the side effect and the record, if the system allows it — and a count of side effects at the end. Anything more than one means the key is not being checked where it matters.
A retry is a second attempt at the same operation, and the system has to treat it that knows that. Give every side effect a key derived from the job and the operation, check it before acting, store the result with it, and let the provider dedupe where it can. The workflow gets slightly slower — one lookup per side effect — and in return a timeout stops being a double charge. When a run is retried, and it will be, the question should never be "did we already do this?" The key should already have answered it.
I'm building AI Automation Lab — production-ready n8n workflow templates. The free AI Hook Generator shows how I structure real workflows; my open-source templates live at github.com/zhp910318/n8n-ai-automation-lab.