DEV Community

vlotstroom
vlotstroom

Posted on

Make your n8n workflows prove they did their job: six patterns for silent failures

A green execution in n8n means the nodes ran. It does not mean any work happened. Three failure shapes stay perfectly green: the source returned zero rows, every row got filtered out somewhere in the middle, or the workflow fired twice and sent the same message twice. Nobody clicks anything in a scheduled workflow, so nobody notices until a customer does.

I maintain a pack of seven n8n workflows for salons and clinics. Reminder bands, waitlist backfill, fee recovery, a weekly KPI digest. Six of them message a real customer's phone on a schedule, so a silent failure is not an inconvenience, it is a missed appointment or a double message. These are the six patterns that came out of hardening them. They are small, they cost nothing, and each one turns one silent failure into a visible row.

1. Validate rows where they enter, and carry the reason

The first Code node in most of my workflows does not transform anything. It validates. Every row gets two extra fields, row_valid and row_error, and the error says exactly what is wrong:

const items = $input.all();
const out = [];
for (const item of items) {
  const r = item.json;
  let rowError = null;
  if (!r.appointment_id) rowError = 'appointment_id missing';
  else if (!r.starts_at || isNaN(new Date(r.starts_at).getTime())) rowError = 'starts_at not parseable';
  else if (r.revenue_eur !== undefined && isNaN(Number(r.revenue_eur))) rowError = 'revenue_eur not numeric';
  out.push({ json: { ...r, row_valid: !rowError, row_error: rowError } });
}
return out;
Enter fullscreen mode Exit fullscreen mode

The next node is an IF on row_valid. Valid rows continue. Invalid rows go to a dedicated log node that emits {error, appointment_id, reason, seen_at} and, in production, writes to an errors sheet. The point is the reason. "6 rows rejected" teaches you nothing at 2 AM. "starts_at not parseable on APT-2002" fixes itself.

2. Zero items stops an IF chain without telling you

An IF node evaluates per item. If the node before it returns zero items, there is nothing to evaluate, and the flow just stops there. Green. No branch taken, no error, no output. If your source is a sheet somebody renamed, this is how your automation dies for a week.

The fix is a sentinel. My digest workflow's prepare node ends with:

if (out.length === 0) {
  out.push({ json: { empty_week: true, row_valid: true } });
}
return out;
Enter fullscreen mode Exit fullscreen mode

Now the empty case is a real item that flows into real branches instead of evaporating.

3. "Nothing this week" is a message, not an absence

Once the sentinel exists, give it its own branch. My weekly digest has a Digest: empty week node that skips the aggregation entirely and sends the owner one honest line: no completed appointments found this week, check that the source is filled. No ratios are computed, so no division by zero, and the owner learns the pipeline is alive precisely because it said "nothing".

That is the heartbeat idea. A weekly report that only exists when there is data teaches you nothing about the weeks when the data quietly stopped. A report that always arrives, even to say "empty", proves the whole chain ran. The same workflow guards this with a once-per-week send log, so the heartbeat itself cannot double-fire.

4. A send log in staticData, keyed by the thing you want once

Double sends come from double triggers: a manual rerun, a webhook retry, two overlapping schedules. The guard is a log in workflow staticData, keyed by a natural key, checked before anything is sent:

// Once-per-key send log, in workflow staticData.
const staticData = $getWorkflowStaticData('global');
staticData.sendLog = staticData.sendLog || {};
const KEEP_MS = 8 * 7 * 24 * 3600 * 1000; // keep 8 weeks of keys
const out = [];
for (const item of $input.all()) {
  const key = item.json.appointment_id + ':' + item.json.stage;
  const alreadySent = staticData.sendLog[key] !== undefined;
  if (!alreadySent) staticData.sendLog[key] = Date.now();
  out.push({ json: { ...item.json, send_key: key, already_sent: alreadySent } });
}
for (const k of Object.keys(staticData.sendLog)) {
  if (Date.now() - staticData.sendLog[k] > KEEP_MS) delete staticData.sendLog[k];
}
return out;
Enter fullscreen mode Exit fullscreen mode

Then an IF on already_sent splits fresh from repeated. Two details matter. Key it on the natural identity, appointment_id plus stage in my reminders, the plain week in the digest, the normalized phone number in booking intake, never the row position. And prune it, or the log grows forever. My intake workflow uses the same pattern as a 30-minute duplicate guard: a repeated POST from a double-clicked form gets the same 200 answer as the first one, marked duplicate_ignored, instead of a second booking.

5. Skip arms write rows too

Every filter branch deserves a terminal that says what happened. Rows outside the send window in my rescue workflow land in a Skip (not due) node that emits {skipped: true, appointment_id, minutes_until, skipped_at}. In production that feeds a log, so at any moment you can count how many rows flowed, how many were skipped and why, and how many were sent. The failure mode this kills is specific and common: a window gate that is too narrow silently filters everything, the workflow stays green, and the only symptom is that nobody got messaged. With skip rows, that condition shows up as a one-glance count.

6. Reply with the right status code

If a webhook workflow answers errors with HTTP 200 and an error message in the body, every caller learns to ignore failures. My intake and reply workflows each carry a Respond invalid node wired to a real 422, with the field-level reasons in the body. The caller's own monitoring sees the failure. The errors also land in a rejected-request log first, so a form that keeps sending one broken field is visible on my side too, not just the caller's.

Test the gates both ways

The last pattern is a test habit, not a node. Gate logic fails in the direction your tests never look at. The worst bug my first harness missed: a send-window IF built by cloning an existing condition, so both bounds ended up gte. gte 20 and gte 30 together let everything through, and every row escalated. The harness fed hours_ago directly, so it never exercised the arithmetic or the operators. The fix in my builders and tests:

  • push one real timestamp, three hours old, through the prepare node and assert the result is about 3.0
  • assert window operators literally, gte on the low bound and lte on the high
  • leak checks both ways: the row that must be filtered never appears past the gate, and the row that must pass does

Feed gates real time values, never precomputed ones, or the test skips exactly the line that converts them.

Where these ship

All six patterns run in every workflow of the appointment pack I sell (€29, Afspraakpack for n8n): validation gates with Dutch error logs, empty-week heartbeats, keyed send logs, skip arms, 422 responders, and the test harnesses that leak-check both directions. The reminders flagship is here, the weekly digest that puts the heartbeat idea into a Monday report is here, and the companion piece on measuring what your AI workflows cost, with its ledger pattern, is here. The push-sync design that keeps a live-edited Google Sheet fresh without re-downloading it every run is here. The free calculators for the scheduling side live at no-show cost, waitlist fill-rate and AI workflow cost.

A green run is a claim. These patterns are how the workflow backs it up.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

The sentinel item for the empty case is the pattern worth stealing, and sending an honest 'nothing this week' line instead of silence turns the digest into a heartbeat. One refinement for the sentinel: an empty week and a broken source look identical to the workflow, but they mean different things. Distinguishing them with a cheap pre-check, such as the source's row count or last-modified date versus the previous run, lets the empty message say 'source has no new rows' versus 'source did not change or was renamed', which is the one the owner can act on. For the double-send case, a send log keyed on appointment id plus the reminder band is usually safer than a time window.

Collapse
 
vlotstroom profile image
vlotstroom •

Agreed on both counts. In the digest version I ship the distinction is still blunt: the sentinel fires the same honest line whether the week was quiet or the source broke, and that line does tell the owner to check the source, but it cannot say which of the two happened.

Your pre-check is cheap with data the digest already keeps. Every Monday it stores a bounded snapshot, four weeks deep, that includes the row count, so the empty branch can compare this week against last week: zero rows against six last week reads as the source probably broke, zero against zero reads as a quiet week. A modification date is harder to get on a Sheet source, the read returns rows and nothing else, so count versus snapshot is the practical version.

On double sends I landed on the same conclusion. The guard I ship is a send log keyed on appointment id plus reminder band, written when the item is accepted. A pure time window re-sent on every manual rerun, which is what pushed me to the keyed version.