Reliability

n8n Schedule Trigger: Build a Daily Digest That Never Double-Sends

Nov 8, 2026 · 8 min read

Your Schedule Trigger is set for 08:00. You get the digest at 08:00 and again at 08:01. Not because the trigger fired twice — because the first run was retried, or you pressed "Execute workflow" to test it, or the instance restarted between the send and the finish. A daily digest needs a lock, not just a cron expression.

Why a schedule trigger alone double-sends

The trigger fires on time. That is all it promises. It does not promise the workflow will finish once. Three normal events break the assumption that one fire means one send:

None of these are bugs in n8n. They are the reason every scheduled sender in production treats "run this once per day" as an application-level guarantee, not a trigger property.

The run-key pattern

Give the day a key, and refuse to send a key that already sent. The key is the workflow name plus the calendar date. For a daily digest that key is always the same shape:

// Code node, right after the Schedule Trigger
const today = new Date()
  .toLocaleDateString('en-CA', { timeZone: 'Africa/Cairo' }); // YYYY-MM-DD
return [{ json: { run_key: 'daily-digest:' + today, today } }];

Pin the timezone here. A digest keyed to UTC sends "today's" numbers at the wrong boundary for anyone not on UTC — the digest goes out at 01:00 local with yesterday's data. Set the timezone once in n8n settings (Settings → Timezone) for every workflow, then reuse the same zone in the Code node so the key and the display date agree.

Now check the store before you build anything. Use the smallest store you already have — a Data Table, a Google Sheet row, or a Supabase table with a unique constraint on run_key. The unique constraint is the point: the check-then-insert race is exactly what a second concurrent run hits, and the database is the only thing that can refuse both.

// Code node, after the store lookup
const already = await $node['Lookup Run'].json();
if (already && already.sent_at) {
  return [{ json: { skip: true, reason: 'already sent ' + already.run_key } }];
}
return [{ json: {
  run_key: $json.run_key, today: $json.today, skip: false
} }];

Branch on skip. The skip path ends immediately — no query, no build, no send. That matters: a skipped run that still queries the CRM and drafts the email is a run that still looks busy in the execution log and can still fail.

Write the lock before you send, not after

Insert the run_key row with sent_at = null before the send nodes, then update it with the real timestamp after the send succeeds. If the run dies mid-send, the row exists and the next run skips — better to miss one digest than to send it three times. A unique constraint on run_key turns a race into a clean error you can branch on instead of a duplicate message.

Manual test mode that cannot send

The manual test is the most common duplicate. Split the workflow at the lock:

Both triggers feed one Code node that computes the digest body. Only the scheduled branch connects to the send nodes. When you verify a change, you use Trigger B, see the rendered digest, and know it never reached a subscriber.

The five-node skeleton

The whole pattern is five nodes plus the digest build itself:

[Schedule Trigger]  (08:00, your timezone)
  |
[Code: compute run_key]        daily-digest:2026-11-08
  |
[Data Table / Supabase: lookup run_key]
  |
[IF skip = true]  ->  [Stop]           (already sent, end quietly)
  |
[Data Table: insert run_key, sent_at = null]   (the lock)
  |
[Build digest]  (CRM query, counts, list)
  |
[Telegram / Email: send]
  |
[Data Table: update sent_at = now]     (close the lock)
  |
[Log: one line to your error workflow if anything threw]

Two details people skip. First, the insert must be a create-if-absent, not an upsert that silently overwrites — an upsert lets two concurrent runs both "win". Second, the final update must run even if the send node throws, so wrap the send in an error branch that writes sent_at and an error column together. A row that never closes is a digest that never retries, which is the correct failure: silent absence beats duplicate delivery for a daily summary.

When the digest must be hourly, not daily

The same lock works for any period. Change the key granularity: digest:2026-11-08T08 for hourly, digest:2026-11 for monthly. Keep the store prune job the same shape too — delete rows older than 90 days in a weekly cleanup workflow so the table does not grow forever. What you should not do is stretch the key to a week for a daily digest: a week-long key means a failed Monday blocks Tuesday through Sunday.

Where this ships today

The run-key, lock-before-send, and manual-preview pattern above is bundled as a ready-to-import workflow on the store. Point the store node at your own Data Table or Sheet, set the timezone, and the next scheduled run sends exactly once — even if you tested it at 07:55.

Want the workflow, not the write-up?

Every Matemplates workflow imports into your own n8n instance — you own it, run it free, and edit it. Grab one and ship today.

Browse the store →