The n8n Production Failure Field Guide
n8n's default failure mode is a green success on a workflow that did nothing. Six silent traps from real self-hosted pipelines, each with the symptom, the failure underneath, and the fix that holds.
n8n lies to you. Green execution status, zero work done. I run real pipelines on a self-hosted instance: content drafting, Slack approvals, lead capture, daily data snapshots. Over a few weeks I collected a set of traps that all share one property. The execution log says success while the work silently never happened. If you operate n8n past the demo stage, you will hit most of these.
| Trap | What it looks like | The fix |
|---|---|---|
| Slack button receiver | Red errors at random in prod | responseNode + default branch + parallel 200 |
| localhost self-call | Downstream workflow never fires | Call localhost, stopWorkflow on dispatch |
| Fan-in multiplication | Runtime explodes, hits rate limit | executeOnce on every read after the first |
| readWriteFile zero items | Create branch never taken | Fan out to a Merge, do not route data through the read |
| Wrong host path | Silent zero items | Confirm the absolute path on the running host |
| CLI strips the error | Errored, no diagnostic | Read executions API with includeData=true |
Trap 1: Slack buttons and the dead Switch branch
I post triage alerts to Slack with three buttons: Approve, Reject, Defer. The send side is in every tutorial. The receive side is where it breaks. Buttons work in testing, then start showing users a red error in production, seemingly at random.
The silent failure: when a user clicks, Slack POSTs to your request URL and expects HTTP 200 within three seconds, or it retries up to three times. Your receiver routes on the button's action_id with a Switch node. If a payload arrives that no branch matches and you have no default branch, that item flows nowhere, and with responseMode: lastNode the webhook returns HTTP 500, "No item to return." Unmatched payloads arrive constantly: during retries, during Slack's probing, during any version mismatch where you renamed an action_id without redeploying.
The fix is three rules. Use responseMode: responseNode, never lastNode, so you control the response. Give the Switch a default branch holding one Set node that writes ok: true and exits. And respond in a parallel branch: one branch fires the empty 200 immediately, the other does the real work at whatever pace it needs. Slack sees the 200 in under 100ms and your work runs untimed. One more detail: Slack retries carry the identical payload, so before any side effect, log the trigger_id to Postgres with ON CONFLICT DO NOTHING RETURNING and short-circuit if the insert returns zero rows. Idempotency is mandatory the moment the workflow has a side effect.
Trap 2: the localhost self-call
Three content cards sat frozen in a revising state for a full day before I noticed. Everything I checked reported success. Workflow A succeeded, but the downstream workflow B it was supposed to trigger never fired, and no error appeared anywhere.
Two compounding mistakes on one HTTP node. The node called the public hostname of the same VPS the workflow was running on, and DNS for that name occasionally fails to resolve from inside the VPS itself (EAI_AGAIN, intermittent, no pattern). That alone would have been loud. But the node also had onError: continueRegularOutput, so the failure became a value in the output payload and the workflow continued past it. Execution log: success. The orchestrator: never poked.
Two load-bearing rules. When one workflow on a VPS calls another on the same VPS, use http://localhost:5678/... and bypass DNS entirely. And notify-style HTTP nodes use onError: stopWorkflow, never continueRegularOutput. A silent half-success is worse than a loud failure: it strands data in a half-applied state with no signal. To find this across your instance, grep your workflows for the external URL in HTTP node URL fields. Anything pointing at your own n8n should be localhost.
Trap 3: executeOnce and the fan-in multiplication
A new snapshot workflow had three Google Sheets reads in a row. First fire: 25 seconds, then HTTP 429 from the Sheets API. Quota burnt, workflow timed out.
By default, every downstream node in n8n runs once per input item. My three reads were 1552, 991, and 983 rows. So the second read fired 1552 times, and the third fired 1552 times 991. That is a 1.5-million-call workflow trying to live inside a 60-request-per-minute quota. The reflex here is to go shopping for more quota, which is the same misread as switching LLM providers to fix a bill: the lever is in the design, not the plan you are on. The fix is one line per node: executeOnce: true on every read after the first. New response: 5 seconds, clean payload.
A related fan-in gotcha lives on the same surface. A Code node reading multiple $('node').all() inputs fails with "node hasn't been executed" if you fan out then fan back in, because n8n runs depth-first and fires the Code node on the first branch before the others run. Chain the reads sequentially instead, set executeOnce plus alwaysOutputData, and filter empties in code.
Trap 4: readWriteFile returns zero items, not an error
A webhook returned HTTP 200, an empty body, and wrote nothing to disk. No error in the logs. Execution status: success. The worst kind of bug. A file-state branch (does this contact exist, create or append) never took the create path, and nothing errored.
The existence check used readWriteFile to read the file, with the error path routed onward to the create branch. But a missing file is not an error to readWriteFile. It reads, reports ok, and emits zero items, and in n8n a node that emits zero items halts that branch quietly. So continueErrorOutput did nothing (no error to route) and continueRegularOutput did nothing (no item to continue). I was trying to catch an exception the platform never throws.
The fix is an architecture change, not error handling: stop routing the real data through the read at all. Fan the inbound data out two ways. One path goes straight to a Merge node. The other does the read and text-extract, then feeds the Merge's second input. Configure the Merge as combine-by-position with "include unpaired items" on. When the file exists, the two items pair and you get the data enriched. When it is missing, the read branch yields nothing, the data passes through alone, and a downstream node decides create versus append on whether the content field is present. The data never depends on a file that might not be there.
Trap 5: the path that does not exist on this host
A related silent zero, different cause. On the VPS, vault reads and writes use an absolute path that exists on the box, not the one from my local mental model. Point readWriteFile at a path that does not exist on the running host and it fails silently with zero items, exactly like the missing-file case. Confirm the absolute path on the machine the workflow actually runs on.
Trap 6: the CLI strips the only error message that matters
When trap 3 first failed, the workflow died in 184ms with no diagnostic. My CLI wrapper returned status: error and nothing else. No node name, no stack, no hint. The actual cause (the 429 quota message) was sitting in the execution record the whole time, just not in what the wrapper printed. The public REST endpoint exposes it behind a flag:
GET /api/v1/executions?workflowId=<id>&limit=1&includeData=true
That returns data.resultData.error with the message, description, node, and stack that point straight at the root cause. Two minutes of curl beats an hour of redeploy-and-see. When your tooling drops the signal, write down where the signal actually lives, then wrap it.
The pattern underneath all of them
Every trap here is the same shape. The platform treats "did nothing" and "succeeded" as the same green. So the discipline that makes n8n safe in production is not more error handling bolted on top. It is designing each workflow so the only way to reach success is to have actually done the work. That is the same instinct behind running a team without pull requests: drop the ceremony that catches nothing, and spend the budget on the checks that actually do.
What separates a workflow that demos from one you leave running
-
Explicit unmatched branch
Every Switch has a default that returns cleanly. No silent dead ends.
-
stopWorkflow on dispatch
A notify node that fails halts loudly instead of continuing green.
-
Data cannot flow through a maybe-empty node
Architect reads out of the critical path with a Merge.
-
Verification beneath the CLI
Read the raw execution record when your tooling strips the signal.
-
Idempotency before side effects
Log and short-circuit on retries so one click stays one action.
Next read
Work Without Pull RequestsRelated service
Work with meStay in the loop
Practical thoughts on engineering leadership, Android, and AI. No spam, unsubscribe anytime.