notes.ludex-gg.com

An n8n Error-Handling Pattern That Makes Silent Failures Impossible

An n8n workflow that runs on a schedule with nobody watching has a specific failure mode: it stops working and nothing tells you. The execution list shows red if you go and look, but nobody goes and looks. You find out when someone asks why the digest email stopped arriving, three weeks later.

This is the pattern I use to make that impossible, and the trap that makes it look like it is working when it is not.

Why the default is silence

By default, a node that throws stops the execution. That is correct behaviour for an interactive workflow you are watching. For an unattended one it means the failure is recorded in a place nobody reads, and the workflow simply produces nothing that run.

Retries do not solve this either. retryOnFail is genuinely useful for transient network errors, but a workflow that retries three times and then fails silently has failed silently. Retry is a reliability feature, not an observability one.

The pattern

Three parts:

  1. Every fallible node gets an error output rather than being allowed to halt the run.
  2. Every error output feeds one shared Build Error Row Set node.
  3. That feeds one Log Error to Sheet node, appending to a single shared errors sheet used by every workflow in the instance.

The important word is shared. The instinct is to give each workflow its own error log. That is the wrong default — it means checking N places, so in practice you check none. One sheet for every workflow, with a column naming which workflow produced the row, is one place to look.

The row shape

Four columns, and the expressions matter more than they look:

Column Expression
Timestamp {{ $now.toISO() }}
Failed Node {{ $prevNode.name }}
Error Message {{ $json.error?.message || $json.message || JSON.stringify($json) }}
Workflow {{ $workflow.name }}

Timestamp is when the error was caught, not when the original event happened. Worth knowing when you correlate against an upstream system's logs.

Failed Node uses $prevNode.name — the node whose error output fed this branch. Since every error path converges on one Set node, this is the only thing telling you where the failure came from.

Error Message is the one that earns its complexity. Different node types put the error in different places. An HTTP Request node's error shape is not a Code node's is not a Google Sheets node's. The fallback chain — $json.error?.message then $json.message then the whole object stringified — is there because any single one of those is wrong for some node type. The final JSON.stringify is deliberately ugly: an ugly row you can read beats a clean row that says undefined.

Workflow is what makes one shared sheet viable instead of N sheets.

If you are wiring this for the first time in an instance, find the existing sheet before creating one. Match the column order and header names exactly — a Google Sheets node writing by column name will silently produce ragged rows if one workflow says Error and another says Error Message. Silent raggedness in the thing you built to catch silent failures is a bad outcome.

The trap

Here is the part that matters most, because it is the failure mode that survives review.

A workflow can validate completely clean while its error handling does nothing.

Setting onError: continueErrorOutput on a node gives that node a second output. It does not connect it to anything. If you set the property and never wire the output, validation passes — the node is configured correctly, after all — and at runtime errors flow into a disconnected output and vanish. You have built the appearance of error handling.

The inverse also happens: an error output wired to the Build Error Row node, but onError never actually set on the node, so the connection is dead and the node still halts the run.

Both states look fine in the editor at a glance. Both pass validation. Neither works.

So "validates clean" is not the completion bar. The bar is:

That last one is the only check that proves the whole path. Point a URL at a host that does not resolve, run it, and confirm the row lands. In particular confirm Error Message is not the literal string undefined — if it is, the fallback chain did not match that node's error shape, and you have found a node type that needs a fifth fallback.

Where the pattern is not enough

Two limits worth stating plainly.

It catches node errors, not wrong answers. A workflow that runs green and writes wrong data produces no rows. If correctness matters, that needs its own assertion step, not error handling.

It catches the workflow, not the trigger. If a schedule trigger never fires, or a webhook is never called, no node ever errors, so nothing is logged. That gap needs a separate watchdog — something that alerts on the absence of an expected run rather than on the presence of a failure.

A real example of the second: a daily content pipeline where the upstream API had begun rejecting requests. It was not throwing in a way that produced rows, it was returning successfully with stale data. The fix was a separate branch that checks whether records have been refreshed in the last seven days and alerts if not — alerting on staleness, not on errors. That is a different question from the one this pattern answers, and it needs a different mechanism.

Applying it

Wire it at build time, on every fallible node, before the workflow ever runs unattended. Retrofitting error handling onto a workflow that has been quietly broken for a month is a worse experience than it sounds, mostly because you no longer trust any of its past output.

The cost is one Set node and one Sheets node per workflow, plus the discipline of forcing one real failure before calling it done. That is a small price for never again finding out about a broken automation from a person instead of a log.