The reason a workflow that ran fine in demos falls apart the moment it hits production is almost always the same. External APIs occasionally return 500s, webhooks occasionally arrive twice, data occasionally arrives empty. You can't eliminate failure. You can only build workflows that handle it.
This post covers the four patterns you have to add the moment you move from demo to production: Retry on Fail, Error Trigger, idempotency keys, and dead letters. Just having these four in place dramatically cuts down on 3 a.m. Slack alerts and frantic log-diving.
1. Turn on retries first — the cheapest defense
For any node that makes a network call, flipping Settings → Retry On Fail alone knocks out 70–80% of transient errors. Things like HTTP 502s, timeouts, DNS failures. The default is 3 tries at 1-second intervals, but in production you'll want to mimic exponential backoff and space them out.
// HTTP Request node config example
{
"retryOnFail": true,
"maxTries": 4,
"waitBetweenTries": 2000, // 2s → 4s → 8s (manually spaced)
"continueOnFail": false
}Continue On Fail lets the next node run even when this one fails. It looks convenient but it's an antipattern that silently swallows failures. If you use it, always check $json.error in the next node.2. Build a failure-only flow with Error Trigger
What happens when retries are exhausted? By default the workflow dies and leaves a trace only in the execution log. Nobody reads that. Build a separate workflow with an Error Trigger node and you can push instant notifications to Slack, email, or PagerDuty the moment things break.
- Create a new workflow → drop in one
Error Triggernode - In the failing workflow's
Settings → Error Workflow, point to the one you just made - Connect a
SlackorEmailnode after the Error Trigger - Include
{{ $json.execution.url }}in the message so you can jump straight to the failed execution - Load the failure payload into a
failed_jobstable via aPostgresnode
3. Idempotency — is it safe to run twice?
Retries aren't a silver bullet. If you send a request to a payment API and the response never comes back, retrying might charge the customer twice. Same for sending emails. The key is to make the operation idempotent — same input, same result, no matter how many times it runs.
The most common pattern is attaching a unique Idempotency-Key header to each request. Stripe, Toss, Shopify, and other major payment and commerce APIs support this header as a standard.
// HTTP Request node Headers
{
"Idempotency-Key": "{{ $json.orderId }}-{{ $now.toFormat('yyyyMMdd') }}"
}
// The server reuses the first response when the same key comes in twiceWhat if the payment API doesn't support the header? Plant a Postgres node at the start of the workflow with INSERT ... ON CONFLICT DO NOTHING so already-processed order IDs get silently skipped.
4. Dead letters — don't throw failed data away
If retries fail and no human sees the alert, where does that data go? Usually into the void. Create a failed_jobs table and keep the original payload, the failure timestamp, the error message, and the retry count. Once you've fixed the root cause, you can batch-reprocess.
payload_json— the entire original request/dataerror_message— the first 200 chars of n8n's error outputnode_name— which node diedretry_count— how many retries were attemptedcreated_at,resolved_at— occurrence and resolution timestamps
What actually happens when you build this
Here's the result of applying all four patterns to one e-commerce client's Order → ERP integration. 30 days before vs. 30 days after rollout.
A trustworthy workflow isn't one that never fails. It's one where the failure is visible and reversible.— SynAct.ai Consulting Team
Wrap-up
There's no such thing as a perfect workflow. But you can build a workflow where the alert lands within 30 seconds, the data doesn't disappear, and one re-run recovers everything after you patch the root cause. The four patterns above are the bare minimum. Treat them as your checklist before promoting anything to production.