This is part of my build-in-public series on running LLM workflows in n8n in production. Everything here is from real templates I sell and support — the mistakes are mine, the fixes are tested.
The workflow was on time. Twice
A workflow of mine ran every hour: pull new rows, enrich them with an LLM call, write the results back. It ran clean for months. Then one day a customer got the same email twice, and a row in the target sheet had two nearly identical updates, minutes apart.
No node was red. Both executions showed "success." The second run was not a retry. It was a second scheduled run, started on time, while the first run was still working.
The first run had slowed down — the LLM provider was slower that morning, and a batch that usually took twenty minutes took seventy. The schedule did not care. At the top of the next hour it fired again, and both runs pulled the same "new" rows, because neither had finished writing yet. The schedule is a clock, not a queue. It will happily start run N+1 while run N is still running.
A schedule is not a lock
The mistake was assuming "runs every hour" means "one run at a time." It does not. A schedule trigger fires when the clock says so, regardless of what the previous run is doing. Any workflow whose runtime can vary — LLM latency, a slow provider, a bigger-than-usual batch — can overlap with itself. And overlap is worse than a crash: a crash is visible, but two successful runs writing the same rows look like success twice.
The fix is not "make the workflow faster." Faster helps until the next slow morning. The fix is a single-flight lock: before a run does any work, it must acquire a lock, and if it cannot, it must stop — loudly, not silently.
A lock with an expiry, not a lock forever
I keep the lock in Postgres, in a tiny table: one row per workflow, with locked_at and a lease_seconds value. Acquiring the lock is a single atomic statement: update the row only if it is unlocked, or if its lease has expired. Only the run that changed the row proceeds. Every other run takes the "could not acquire lock" branch and stops.
The expiry is the part that matters most. A lock without an expiry turns a crashed run into a permanently stuck workflow: the run dies, the lock stays, and every later run skips forever. So the lease must be longer than a normal run but shorter than "someone will notice." Mine is twice the usual runtime. A run that holds the lock past its lease is presumed dead, and the next run may reclaim it — but it must log that it reclaimed a stale lease, because a reclaimed lease may mean the previous run is still alive somewhere, writing rows.
That gives three distinct outcomes, and they must not share a message:
- Acquired: normal run, proceed.
- Already locked: previous run still inside its lease. Skip this run, and alert with "skipped because previous run still running" — not silence, and not an error. A skipped run is information: the batch is falling behind.
- Stale lease reclaimed: previous run exceeded its lease. Proceed, but alert differently, because this run may now overlap with a zombie. The alert must name the workflow and the lease age, so a human can check whether the old run actually died.
Release the lock last, and only if you hold it
The run releases the lock at the very end, and only if it still holds it: update the row only where the lock token matches the one this run acquired. A run that lost its lease mid-flight must not release a lock that a newer run now holds. That one condition — release only your own token — is what keeps the reclaim path from turning into two runs both believing they are the only run.
There is one more trap: do not put the release in a branch that only runs on success. A run that fails halfway and never releases its lock will block the next run until the lease expires. The release belongs in the workflow's always-run path, the same place error handling lives, so a failed run frees the lock immediately and the next scheduled run can proceed on time.
The test is a slow run, not a fast one
Testing this on a fast run proves nothing, because a fast run never overlaps. The test that matters is a deliberately slow one: start a run, hold it past the next scheduled fire time — a Wait node, or a slow mock provider — and assert three things. First, the second run skips and reports "previous run still running." Second, no row is processed twice: count effects at the target, not executions in n8n. Third, after the first run releases, the next run proceeds normally, and a run that is killed mid-flight does not block the schedule past its lease.
That test is also how you pick the lease length. If the lease is shorter than a slow-but-legitimate run, you will reclaim leases on healthy runs and manufacture the overlap you were trying to prevent. If it is too long, a dead run blocks the schedule for hours. Twice the usual runtime, measured on a slow day rather than a good one, is where I landed.
The principle
A schedule guarantees when a run starts. It guarantees nothing about when the previous run finishes. If your workflow touches shared rows, sends messages, or writes to a target system, "runs every hour" is not a concurrency policy — it is a hope. A lock with an expiry, a token-checked release, and a skipped-run alert turns that hope into a rule: at most one run doing the work, and every skipped run accounted for.
I'm building AI Automation Lab — production-ready n8n workflow templates. The free AI Hook Generator shows how I structure real workflows; my open-source templates live at github.com/zhp910318/n8n-ai-automation-lab.
Top comments (0)