The dedupe that passed every test and was never a dedupe
An n8n workflow deduplicated inbound leads using
$getWorkflowStaticData(), n8n’s own documented way to keep
a value between executions without standing up a database. It was tested for a
full session and behaved correctly every time. Duplicates were caught. State
survived across two completely separate Docker containers sharing only a named
volume — the second run correctly detecting the first’s record.
Retention pruning worked. The tests were repeated after a schema change and
passed again.
Every one of those tests was sequential.
That is the whole problem, and it is not a testing failure so much as a category error. Sequential tests establish observed behaviour. The property the workflow actually depended on — that two executions cannot both pass the duplicate check with the same key — is a concurrency guarantee, and no number of sequential passes speaks to it.
Worth being precise about how this went wrong, because the tempting version
is false. It is not that nobody noticed. The limitation was written down, in
advance, in the project’s own documentation: concurrent-execution safety
of $getWorkflowStaticData “has not been established —
see the idempotency design doc’s open risk.” The risk was named in
writing, and the documentation was upgraded from “design-level
only” to “proven durable” anyway.
Naming a risk is not the same as pricing it.
What the source says
Ten minutes in n8n 2.33.7’s own source settled it:
Workflow.getStaticData()returns an in-memory object created per execution, loaded from the workflow row when that execution starts.WorkflowStaticDataService.saveStaticDataById()issuesUPDATE workflow SET staticData = <entire blob> WHERE id = ?— a full overwrite. No merge. No compare-and-set.- It is called from the
workflowExecuteAfterlifecycle hook — after the execution finishes, not at the moment the Code node mutates the object.
That is a read-modify-write race with last-write-wins. The decisive detail is the third one: no code inside a Code node can close this race, because the node never performs the write. Retry logic does not help. A better key does not help. Checking a second time before writing does not help. The gap is between the node finishing and n8n persisting, and that gap is not the workflow’s to control.
This is also why the database underneath does not rescue it. The reproduction ran on SQLite, and a reader on Postgres will reasonably ask whether isolation level changes the answer. It does not: the write is a whole-blob overwrite issued after the execution has ended, so there is no transaction still open for an isolation level to protect.
Predicted, then reproduced
Predicted from the source first, then reproduced on the first attempt:
- Two concurrent executions with the same key — both accepted. The duplicate was not caught.
- Three concurrent executions with three distinct keys — all three leads were correctly accepted, but only one of the three keys persisted. Two keys were silently discarded, so those two leads would not be recognised as duplicates if they arrived again.
Be precise about the second result, because it is both the more dangerous one and the one easiest to overstate. No lead was destroyed. What was destroyed was the memory that those leads had been seen — and nothing reports that. No error is raised, no execution is marked failed, and n8n’s execution list shows three clean green runs. The failure is silent, and it surfaces later as duplicates nobody can explain.
The same pattern, read off an export
This is detectable statically, without running anything. Against that workflow the analysis reports, among other findings:
[HIGH] STATIC_DATA_CHECK_AND_SET — State is kept in n8n workflow
static data, which is not atomic
nodes: Idempotency Check (design-level)
proof: These Code nodes call $getWorkflowStaticData() and also
write. n8n gives each execution an in-memory COPY of
static data and writes the whole blob back when the
execution ends — so a check-and-set built on it is a
read-modify-write race. No recognised trigger here can
fire concurrently, which narrows but does not remove the
exposure — a manual re-run or a queue-mode instance
still can.
NOT proven: Whether the race has actually cost this client a record.
That needs their execution history, which is not in the
export. Also not proven: what the stored keys are — the
node was not run.
Note the clause that makes the finding less alarming: “No recognised trigger here can fire concurrently.” The analysis volunteers it. A finding that only ever argues in one direction is a sales document with line numbers.
What this does not establish
- It does not prove any workflow has lost data. It proves the race exists and is reachable.
- The workflow in question is behind a manual trigger. There is no live webhook or form in it, and the race was reproduced by firing concurrent executions deliberately. That narrows the exposure considerably for this workflow — a manual re-run or a queue-mode instance still reopens it.
- It applies where state is in static data and executions can overlap. Not to every n8n workflow.
- It is version-specific. n8n 2.33.7. Re-check against the version you actually run rather than assuming it carries forward.
- The same run reports two other findings on that workflow — no error handling wired anywhere, and four terminal paths that record nothing. Both are defensible in a manual-trigger MVP whose terminals are deliberately no-send. Neither would be defensible behind a live trigger.
If you want this done to yours
Send the export and tell me what the workflow is for. You will get the findings, what each one does not prove, and the NOT CHECKED list. If there is nothing worth reporting, I will say so.