Module 2: Idempotency: Making Repeats Not Duplicate

6. The check-then-act trap

Description

By the end of this lesson you'll be able to recognize, and avoid, the whole module's subtlest mistake: the "check if it exists, and if not, create it" pattern, which looks idempotent and isn't. You're going to understand why it fails —a race condition, the gap between checking and acting where another execution fits in—, why that failure survives every one of your tests intact and only blows up in production, and why the correct solution doesn't live in two separate nodes but inside a single atomic operation: lesson 4's upsert or lesson 5's header.

This matters because the check-then-act pattern is the solution everyone thinks of first. It's intuitive, it reads well, and it works in every test you run against it. That's exactly why it's so dangerous: you don't discover it, production discovers it, weeks later, with a duplicate charge you swore you'd prevented. This lesson is the vaccine. It's probably the lesson that most separates someone who understood idempotency from someone who just copied an upsert without knowing why it was better than two nodes.

Connection to the module: lessons 4 and 5 gave you the robust solutions —the atomic upsert and the idempotency header—. This lesson explains why they're robust, contrasting them with the tempting alternative that isn't. In lesson 5 I planted the phrase "race window" twice; here we harvest it. It's the module's most conceptual lesson, and its lesson applies to everything that follows: module 5's coordination and module 6's retries are only safe if you avoid this pattern. Lesson 8's project is explicitly going to ask you not to fall into it.

The pattern everyone thinks of

When an API doesn't give you an idempotency header, or when you write to a system with no uniqueness constraint, the universal instinct is this:

1. Check: does a charge for ORD-2041 already exist?
2. If NOT → create it.
   If YES → do nothing.

In n8n it looks like three chained nodes:

HTTP Request              If                    HTTP Request
(GET: does the      ──►  (does the        ──►  (POST: create the charge,
 charge exist?)            response say no?)     only if the If lets it through)

Read it and tell me it doesn't sound perfect. "Before charging, I check whether I already charged; if I already charged, I don't charge again." It's exactly what you want to happen. And that's exactly why it's so treacherous: the logic is correct, the problem is time.

Why it fails: the gap between checking and acting

Here's the heart of the lesson, and it's worth reading slowly.

Between step 1 (checking) and step 2 (creating), time passes. Not much —milliseconds, maybe— but it passes. The HTTP Request node that checks finishes, the If node evaluates, and only then does the HTTP Request node that creates begin. In that interval, your execution doesn't have exclusive control of the world. Something else can happen.

What else? Another execution of the same workflow, triggered by the webhook's second arrival. Remember: Cumbre's webhook fires twice, almost at the same time. n8n can process both executions in parallel —especially with several workers, but even without them, two nearly simultaneous triggers overlap—. And that's where the disaster happens. Let's follow the clock, execution A and execution B, with the same ORD-2041:

t=0ms   A: GET does a charge for ORD-2041 exist?  → API responds: NO
t=1ms   B: GET does a charge for ORD-2041 exist?  → API responds: NO
                                                       (still does not exist! A has not created it yet)
t=2ms   A: the If lets it through → POST create charge → ch_777 gets created
t=3ms   B: the If lets it through → POST create charge → ch_888 gets created

Read it again. Both executions asked "does it exist?" Both got "no," because at the instant they asked, it genuinely didn't exist —A hadn't reached the creation step yet—. Both passed the If. And both created a charge. Result: ch_777 and ch_888. Two charges. The "check then act" pattern, designed to prevent exactly this, prevented nothing.

The problem isn't that the logic is written wrong. The logic is flawless. The problem is that the check and the action are two separate operations, with a gap in between, and in that gap the check's answer stops being true. B checked "doesn't exist," and by the time B acted, that answer was already stale —A had made it false—. B acted on expired information.

This phenomenon has a name in systems design: a race condition. It's called that because two executions "race" and the result depends on who gets to each step first, a detail you don't control. The specific case —checking a state and acting on that check, when the state could have changed in between— is known as TOCTOU, from time-of-check to time-of-use: the time between checking something and using it. Disaster fits between the "check" and the "use."

The analogy: the flight's last seat

Think of it with two people buying the last seat on a flight, each on their own computer, at the same time.

Ana opens the page: "1 seat left." Beto opens the page the same second: "1 seat left" —both see the same available seat, because neither has bought yet—. Ana clicks "buy." Beto, an instant later, also clicks "buy." If the airline's system is poorly built, it sells the seat twice: Ana and Beto show up at the airport with tickets for the same spot. Both "checked" there was a seat, both "acted" by buying, and between their check and their purchase, the availability they saw stopped being true.

A serious airline doesn't work that way. When Beto clicks "buy," the system doesn't trust what Beto saw a moment ago; at the exact moment of selling, it re-checks availability and reserves it in a single indivisible operation, so that if Ana already took it, Beto's purchase fails right there with "seat not available." The check and the sale happen glued together, with no gap. That's exactly what an upsert does, and it's what "check then act" can't do, because it's two steps with air in between.

Why it survives your tests

This is the cruel part, and the reason this bug is so expensive.

When you test your workflow, you trigger it once. You look at the result, a single charge, everything's fine. Maybe you trigger it a second time to "test the idempotency" —but you trigger it after the first one finished, sequentially—. The second execution checks, finds the charge already exists (because the first one already finished creating it), and doesn't create another. It works! You conclude the pattern is idempotent and ship it to production.

The problem is your test never reproduced the condition that breaks the pattern: two executions overlapping in time. You tested A-then-B (sequential), and sequentially the pattern genuinely works. What fails is A-and-B-at-once (concurrent), and that only happens when the webhook fires double in production, with both executions running almost together. Your manual test, done by a human who clicks once and waits, is incapable of creating that overlap.

That's why the "check then act" pattern is a wolf in sheep's clothing: it passes every reasonable test and fails only under real concurrency. It's the definition of a bug that survives development and blows up in production. And when it blows up, it's hard to diagnose, because when you go to reproduce it —triggering the workflow by hand— it works again, and you're left staring at the screen not understanding why it fails in production. The answer is always the same: production had concurrency; your test didn't.

Hold on to this question, because it's the one that defuses the bug before it's born: "what happens if two copies of this workflow run at the same time with the same event?" If the answer involves "both check and both act," you have a race condition, no matter how well it works in your sequential test. It's the same question lesson 3 taught you for keys, turned toward concurrency: there it was "would my key be identical if the event arrived again?"; here it's "would my logic still be correct if the event arrived again at the same time?". Both questions, asked by reflex, catch the vast majority of duplicate bugs before they reach production.

The solution: a single atomic operation

If the problem is the gap between checking and acting, the solution is eliminating the gap: making the check and the action the same indivisible operation, one nothing can split in half. That's what atomic means: an atomic operation happens whole or doesn't happen, and nothing can slip inside it.

And who knows how to do atomic operations? The database. And a well-designed API. Exactly the two tools from lessons 4 and 5.

The upsert is atomic. When you write INSERT ... ON CONFLICT (order_id) DO NOTHING, you're not doing "check then insert" in two steps. You're giving one single instruction, and the database, internally, guarantees that the conflict check and the insertion happen glued together, protected by the uniqueness constraint. Let's revisit the clock, now with the upsert:

t=0ms   A: INSERT ORD-2041 ON CONFLICT DO NOTHING  → inserts ch_777
t=1ms   B: INSERT ORD-2041 ON CONFLICT DO NOTHING  → collides with the
                                                       uniqueness constraint → DO NOTHING

When B tries to insert, the database already has A's row —because the uniqueness constraint is a lock A took when inserting— and DO NOTHING absorbs B's attempt. There's no gap where B can "see it doesn't exist," because B doesn't check on its own: it asks the database to insert, and the database, in a single operation, decides it already exists. The atomicity comes from the database engine, not your workflow.

The idempotency header is atomic on the server side. When you send the same Idempotency-Key twice, the gateway does the check "have I already seen this key?" and the action "create or return the existing one" as a single protected operation on its side. Just like the serious airline that checks-and-reserves in one step. You don't have the gap because the server closed it.

The key difference, then, between the fragile pattern and the robust one isn't how many nodes you use or how clever your logic is. It's who enforces the uniqueness. In "check then act," you enforce it, with two nodes and an If, and you can't enforce it atomically because n8n runs the nodes one after another, with time between them. In the upsert, the database enforces it, and it does know how to be atomic. You delegate the uniqueness to whoever knows how to guarantee it with no gaps.

Worked example: the same goal, the fragile node and the robust node

Cumbre needs to record in its orders table that ORD-2041 was processed, without duplicating. Let's see both forms.

The fragile form (three nodes):

Postgres (SELECT)         If                     Postgres (INSERT)
SELECT * FROM orders  ──► did the query      ──►  INSERT INTO orders
WHERE order_id =           return 0 rows?         VALUES (...)
'ORD-2041'                 (meaning it does not
                             exist)

What to expect in a sequential test: it works. You trigger once, it inserts. You trigger again (later), the SELECT finds the row, the If doesn't let it through, it doesn't insert. A single row. Looks idempotent.

What to expect under real concurrency: it fails. Two overlapping executions both do the SELECT before either inserts, both see "0 rows," both pass the If, both insert. Two rows. And if the table doesn't even have a uniqueness constraint, both stay there, with no error.

The robust form (one node):

Postgres (Execute Query)
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ('ORD-2041', 'CUST-118', 1780, 'pending')
ON CONFLICT (order_id) DO NOTHING;

What to expect under real concurrency: it works. Both executions send the same INSERT ... ON CONFLICT. The first inserts; the second collides with the uniqueness constraint and DO NOTHING absorbs it. A single row, even running at exactly the same time. One node, not three, and more robust on top of that.

Pause on the irony: the robust solution has fewer nodes than the fragile one. You're not adding complexity to gain safety; you're removing complexity. The SELECT + If wasn't just unsafe, it was extra work. Delegating the uniqueness to the database is simpler and more correct. You can almost never have both at once; here you can.

But sometimes there's no upsert or header: then what?

Let's be honest: there are situations where the API offers no header, the system has no uniqueness constraint, and you can't set by PUT. Are you doomed to the race condition?

No, but the solution isn't "do the SELECT+If more carefully" —that doesn't close the gap—. The solution is bringing the atomicity to a place you control: your own database.

The idea, which module 4 develops in depth, is this: before triggering the fragile effect, you record the event in a table of yours with a uniqueness constraint, via an upsert. If the upsert inserts (the key was new), you proceed with the effect. If the upsert doesn't insert (the key was already there), you don't trigger the effect, because someone else already did. Your table's uniqueness constraint becomes the atomic referee that decides who has permission to trigger the effect —and since it's atomic, two concurrent executions can't both "win" the permission—.

In other words: when you can't make the effect itself atomic, you make the decision to trigger it atomic, resting on your own database. It's "check and act," but with the "check" turned into an atomic upsert instead of a SELECT+If. The gap disappears because the check is now indivisible.

Don't build it yet —it's module 4's ledger—. For now, hold on to the principle: uniqueness must always be enforced by something that knows how to be atomic (a database with a uniqueness constraint, or a server-side API), never by two separate nodes in your workflow. If you find yourself building a SELECT+If+INSERT, stop: there's an atomic way, and if you can't see it, it means the atomic referee has to be your own table.

An honest variant: let the database say "no"

There's a way of thinking about idempotency that sometimes feels more natural than DO NOTHING, and it's worth knowing because it shows up a lot in real life: attempt the operation, and if the database rejects it for violating uniqueness, treat that rejection as the signal "it was already done."

Instead of ON CONFLICT DO NOTHING, you do a plain INSERT —with no conflict clause— and trust the uniqueness constraint to fail when the key already exists:

t=0ms   A: INSERT ORD-2041   → success, inserted
t=1ms   B: INSERT ORD-2041   → ERROR: violates the uniqueness constraint

B's error isn't a problem; it's information. The database is telling you, atomically and with no gaps, "this key already exists, I didn't re-insert it." Your workflow, instead of treating that error as a failure, recognizes it as "duplicate detected" and moves on calmly: the effect was already done by execution A.

This is subtle but important: it's still atomic. The uniqueness constraint is the referee, same as with DO NOTHING; the only difference is that here "already exists" reaches you as an error you interpret, instead of as silence. It's the same protection with different ergonomics. You choose it when you want to explicitly know there was a duplicate —to count it, to log it, to skip steps that only make sense the first time—.

The connection to what's coming: handling that error gracefully —telling apart "uniqueness error = expected duplicate, I move on calmly" from "real error = something broke, needs an alert"— is exactly the kind of error handling module 6 covers in depth. For now, hold on to this: a uniqueness error isn't always a failure: sometimes it's your idempotency mechanism working, telling you out loud what DO NOTHING tells you silently.

What this variant is not: it isn't "check then act." Notice the crucial difference. Here there's no prior SELECT that checks; there's a direct INSERT that attempts and lets the database decide atomically. There's no gap between checking and acting because there's no separate check: the attempt is the check. That's the whole difference between fragile and robust.

Common mistakes

Believing "check then act" is idempotent because it worked in testing (conceptual). What happens: the three-node pattern gets built, it's tested by triggering once and then again, it works both times, and it's deployed with confidence. In production, under concurrency, it duplicates. Why it happens: manual testing is sequential —a human triggers, waits, triggers— and sequentially the pattern does work; the concurrency that breaks it only shows up with nearly simultaneous triggers, which a person can't reproduce by hand. How to spot it: ask yourself this lesson's question —"what happens if two copies run at the same time with the same event?"— instead of trusting the sequential test. If the answer is "both check, both create," it's fragile, whether or not it worked in your test. How to fix it: replace the SELECT+If+INSERT with an atomic INSERT ... ON CONFLICT (upsert), or with the idempotency header if the effect is an API call. Delegate the uniqueness to something that knows how to be atomic.

Adding the checking SELECT on top of an upsert (practical). What happens: someone, out of excessive caution, puts a SELECT that checks for existence before an upsert that already, on its own, doesn't duplicate. The SELECT isn't just unnecessary —the upsert already handles the "already exists" case— it reintroduces a gap and gives a false sense that "the check" is what's protecting. Why it happens: there's not full trust in the upsert, or it isn't understood that the upsert already includes the check, atomically. How to spot it: if you have a SELECT followed by an upsert on the same key, the SELECT is extra. How to fix it: remove the SELECT. The upsert does the check and the action as a single atomic operation; putting a manual check in front of it adds no safety, it adds noise and a window. Trust the database's atomicity.

Assuming n8n never runs the same workflow in parallel (conceptual). What happens: someone reasons "my instance is small, it runs a single process, so there's no concurrency" and leaves the SELECT+If+INSERT in place. Then the webhook fires double, both executions overlap enough, and it duplicates. Why it happens: how much two nearly-simultaneous triggered executions overlap gets underestimated; even without several workers, processing two concurrent webhooks can interleave, and with queue mode or several workers the concurrency is explicit. How to spot it: don't assume anything about your instance's concurrency; design as if two executions could overlap, because at some point they will. How to fix it: always use atomic operations for uniqueness. The upsert's atomicity protects equally well whether there's one worker or ten; it doesn't depend on how much real concurrency you have, and that's why it's the safe default choice.

Exercises

Exercise 1 — Find the gap. This is a coworker's design for not duplicating an email send. Trace the timeline of two concurrent executions (A and B) of the same event and show how it ends up sending two emails. Then say where the gap is.

1. HTTP Request (GET): does a "email sent for ORD-2041" record exist?
2. If: does the response say it does NOT exist?
3. HTTP Request (POST): send the email
4. HTTP Request (POST): record "email sent for ORD-2041"
See solution

The timeline:

t=0   A: GET does a record exist? → NO
t=1   B: GET does a record exist? → NO   (A has not recorded anything yet)
t=2   A: If lets it through → sends the email (email #1)
t=3   B: If lets it through → sends the email (email #2)   ← DUPLICATE
t=4   A: records "sent for ORD-2041"
t=5   B: records "sent for ORD-2041"   (or fails if there is uniqueness, but the email already went out)

The gap is between step 1 (checking) and step 3 (sending). Both executions check "doesn't exist" at moments 0 and 1, before either has recorded anything, so both pass the If and both send. Step 4's recording arrives after the send, too late to prevent the second email.

And there's an aggravating factor specific to emails: even if you put a uniqueness constraint on step 4's record —so the second record would fail— email #2 already went out at step 3. An email can't be un-sent. For irreversible effects, check-then-act is especially dangerous, because the harm happens before the record can prevent it.

The fix: reverse the order and make it atomic. First record the event with an atomic upsert (INSERT ... ON CONFLICT DO NOTHING); only if the upsert inserted (the key was new), send the email. That way, of two concurrent executions, only one "wins" the upsert and sends; the other collides with the uniqueness constraint and doesn't send. The upsert's atomicity decides who has permission, before triggering the irreversible effect.

Why this works: you saw the gap isn't where you'd look first (the send), but in the check that happens too early and too separated from the action. And you saw that for irreversible effects, you have to win the permission atomically before acting, not check afterward.

Exercise 2 — Why doesn't the upsert have the gap? In your own words, explain why INSERT ... ON CONFLICT (order_id) DO NOTHING doesn't suffer the race condition that SELECT+If+INSERT does, even though both "check if it exists and act on that."

See solution

The difference is who checks and when, and above all whether the check and the action are separable.

In SELECT+If+INSERT, you check (with the SELECT), and the check is a complete operation that finishes before the action (the INSERT) begins. There's a time gap between the two, and since they're distinct operations run by distinct nodes, another execution can slip into that gap and make your check stale.

In INSERT ... ON CONFLICT DO NOTHING, the database checks, and it does so inside the same indivisible operation as the insertion. There's no moment where the check has finished but the insertion hasn't started; they're a single thing. The uniqueness constraint acts as a lock: when A inserts, it takes the lock on ORD-2041, and when B tries to insert, it runs into that lock and DO NOTHING absorbs it. B never sees a moment where ORD-2041 "doesn't exist yet but I can insert," because the database serializes those attempts atomically.

In one sentence: SELECT+If+INSERT splits the check and the action into two, leaving a gap; the upsert fuses them into one atomic operation with no gap, and delegates the arbitration to the database's uniqueness constraint.

Why this works: you named the exact property that makes the difference —atomicity— and saw that it isn't about "checking better," but about the check and the action being inseparable. No extra care in the SELECT closes the gap; only fusing it with the action closes it.

Exercise 3 — Redesign without the trap. Cumbre calls an inventory API that offers neither an idempotency header nor a PUT by id: it only has GET /stock/{sku} and POST /stock/reserve. A coworker proposes: "I do a GET to see if I already reserved, and if not, I do a POST to reserve." Explain why that can reserve twice under concurrency, and propose a redesign that uses your own database as the atomic referee.

See solution

Why the coworker's design fails: it's a classic check-then-act. Two concurrent executions of the same event both do the GET before either reserves, both see "not reserved," and both do the POST /stock/reserve. Double reservation. The inventory API can't prevent it because it offers no idempotency mechanism, and the gap between the GET and the POST is where the second execution slips in.

The redesign with its own atomic referee:

1. Postgres (upsert): INSERT INTO reservations (idempotency_key)
   VALUES ('<key for ORD-2041>')
   ON CONFLICT (idempotency_key) DO NOTHING
   RETURNING idempotency_key;     -- returns the key ONLY if it inserted

2. If: did the upsert return a row? (that is, was THIS execution the one that inserted?)
   - YES → this execution "won the permission" → POST /stock/reserve
   - NO  → another execution already reserved → do nothing

The key to the redesign: the reservation decision becomes atomic, resting on your reservations table's uniqueness constraint. Of two concurrent executions, the upsert only lets one insert the key (the other collides with the uniqueness and doesn't get the row back). Only the one that inserted triggers the POST. The inventory API is still not idempotent, but you no longer call it twice, because your database atomically decided who had permission to call it.

(This is exactly the pattern module 4 formalizes as a deduplication ledger, and that module 5 uses as the foundation for the outbox pattern. Here you just saw its essence.)

Why this works: you understood that when the effect itself can't be atomic, you move the atomicity to the decision to trigger it, and that decision can live in an upsert on your own table. The uniqueness is enforced by something that knows how to be atomic —your database— and not by two separate nodes.

Summary and next step

In this lesson you defused the module's subtlest trap: the "check if it exists, and if not, create it" pattern, which looks idempotent and isn't. You saw why it fails —the gap between checking and acting, where a second concurrent execution slips in, checks "doesn't exist" with information that stops being true an instant later, and creates the duplicate—, a phenomenon with its own name: race condition, and in its specific form, TOCTOU (time-of-check to time-of-use). You understood why the bug is so expensive: it survives every sequential test and only blows up under production's real concurrency, which a human clicking can't reproduce. And you saw the solution: eliminating the gap by making check and act a single atomic operation —lesson 4's ON CONFLICT upsert or lesson 5's header—, delegating the uniqueness to something that knows how to be atomic (the database, the server-side API) instead of to two separate nodes. And for when there's neither an upsert nor a header, the principle module 4 will build: bringing the atomicity to your own table, making the decision to trigger the effect atomic.

Before moving on to lesson 7 you should be able to: recognize a SELECT/GET + If + INSERT/POST as a race condition; explain why the upsert doesn't have that gap; and ask yourself, for any effect, the question that defuses the bug —"what happens if two copies run at the same time with the same event?"—.

Up to now, the effects were triggered by your workflow directly: an HTTP Request you set up, a database node you configured. Lesson 7 raises the stakes with the trendy case: an AI Agent that decides on its own which tool to call and when. An agent can call a tool with an effect —charging, sending, creating— twice, or give slightly different outputs on each run for not being deterministic. You're going to see how to wrap those tools with an idempotency key so the agent's loop doesn't execute the same effect twice, applying everything from this module —including, very much in particular, the lesson you just learned about not falling into check-then-act—.

Resources