Module 6: Retries, Alerts, and Recovery
6. Reproducing and tracing a duplicate bug with replay
Description
By the end of this lesson you will be able to take the worst kind of bug — "sometimes Cumbre issues two refunds, I don't know when or why" — and turn it into a reproducible, fixable one. You will use n8n 2.0's debugging engine to load the data of a real execution that already happened inside the editor, run it again step by step, and trace the idempotency key through every node until you see exactly where the second effect got created. You will learn the difference between Retry with original workflow and Retry with currently saved workflow, when to use each one, and how to follow a variable's value inside a Code node without guessing. The result: a method for catching intermittent bugs, which are the ones that cost the most time and cause the most frustration.
This matters because a bug you can't reproduce, you can't fix with confidence. You can believe you fixed it, change something, and since the bug showed up one time in a hundred, go two weeks without seeing it and conclude it's resolved — until it comes back. Replay breaks that cycle: instead of waiting for the bug to happen again by chance, you take the exact execution where it happened, reconstruct it in your editor with its real data, and observe it as many times as you want. An intermittent bug in production becomes a deterministic bug on your screen. That's the difference between debugging with method and debugging with luck.
Connection to the module: this lesson uses what lesson 5 stored. When a failure fell into the dead-letter queue, you saved its execution.id — that identifier is the key that opens replay. And the bug you're going to hunt is precisely the one every previous lesson tried to prevent: a duplicated effect, which here shows up when idempotency protection (Module 2) had a crack. Replay is the diagnostic tool that closes the module before the capstone: retrying (2) and compensating (3) react to the failure, alerting (4) and the queue (5) capture it, and replay (6) lets you understand it. In the capstone you're going to use a replay as proof that a duplicate trigger doesn't create a second effect.
The worst bug: the one that shows up one time in a hundred
Let's start by understanding why this kind of bug is so especially hard, because the lesson's method is designed against that difficulty.
Most bugs are deterministic: you do X, Y happens, always. You fix them because you can reproduce them at will — you repeat X, you see Y, you change something, you repeat X, you no longer see Y. That "reproduce, change, verify" cycle is the backbone of debugging.
An intermittent bug breaks that cycle at its very first step. "Sometimes two refunds go out" means most of the time only one does. You can't reproduce it at will: you can process fifty orders and have all of them come out fine, not because you fixed it, but because the exact combination that triggers it didn't happen. And since you can't reproduce it, you can't verify a fix: you change something, process twenty orders that come out fine, and you don't know if it was your change or luck.
Intermittent bugs almost always come from a race condition or from state that depends on timing: two things happening "almost simultaneously" in an order that's normally harmless but that, at the exact right instant, produces the problem. At Cumbre, the number one suspect is Module 2's "check then act" trap: the webhook fires twice almost simultaneously, both executions check the ledger before either one has written to it, both see "this order hasn't been processed," and both move forward and issue a refund. Two refunds. And it only happens when the two triggers fall within the millisecond window where neither one managed to write yet — one time in a hundred.
The analogy is an airplane's black box. An incident that happens once in thousands of flights is impossible to reproduce by asking the pilot "fly again and see if it happens." What makes it possible to investigate is that the plane recorded everything that happened on that exact flight: every piece of data, every action, in order. Investigators don't reproduce the flight; they reproduce the recording. n8n's replay engine is that black box: every execution got recorded with its real data, and you reproduce the recording of the execution that failed, not a new execution with your fingers crossed.
n8n 2.0's debugging engine
n8n stores each execution's data — what went into each node, what came out — and lets you load it back into the editor. Let's look at the two tools that matter.
On labels and availability. This guide was written with n8n 2.x in July 2026. The exact names of the buttons and the detail of what can be reloaded have evolved between versions, and this is something worth verifying in your own panel. Also, for there to be executions to reload, your instance has to be saving executions — including failed ones: it's an option in the workflow's settings, and without it there's no recording to reproduce. The concept is stable; the button text and the option's location, verify them.
Debug in editor. It's the lesson's central tool. From the executions list, you take a past execution — for example, the one that issued the double refund — and choose to debug it in the editor. According to the official documentation, n8n copies that execution's data into your current workflow and pins it to the first node. From there, when you run the workflow, no new webhook event arrives: the exact data from that execution arrives, frozen. You reproduce the recording.
This is what turns an intermittent bug into a deterministic one. The exact combination that triggered the duplicate — the precise data, in the precise state — no longer depends on the luck of the webhook firing twice within the right window: it's pinned to the first node, and you can run it over and over, watching every step.
Retry (retrying the execution). From the executions list, besides debugging, you can relaunch a whole failed execution. There are two variants, and the difference matters:
- Retry with original workflow: re-executes using the workflow as it was when the failure happened. It's for confirming the original behavior without your recent changes muddying it.
- Retry with currently saved workflow: re-executes with the current version of the workflow — the one you may have already modified to fix the bug — but using the old execution's data. It's how you verify a fix: same data that failed, corrected workflow, does it work now?
The practical distinction: you use Debug in editor to understand the bug — load the data and trace step by step — and Retry with currently saved workflow to verify the fix — the same data that used to break it, running through your corrected version.
And the usual warning, which is easy to forget in replay: reproducing an execution re-executes its effects. If you reload and run the double-refund execution against your system connected to the real payments API, you could issue more real refunds. To debug a duplicate bug you never do it against the real API; you do it against a test environment, or by disconnecting the real effect, or — simplest for tracing — by observing the data without letting the effect node ever call anything. You'll see how in the worked example.
Tracing the idempotency key step by step
Replay puts the correct data in front of you; tracing is what you do with it. Tracing a variable means following its value node by node to see where it stops being what you expected. For a duplicate bug, the variable you trace is the idempotency key: if at some node the key changed, or got computed differently, or the check against the ledger gave an unexpected result, that's where the crack is.
n8n 2.0's Code node restriction narrows your tools, and that's fine, because what's left is enough:
console.log()writes to the browser console (not to the output panel, as you saw in earlier guides). It's for leaving traces:console.log('key at this node:', key).- Returning the variable in the output is more convenient for tracing: you temporarily add the value you want to inspect to the object the node returns, and you read it in the OUTPUT panel without switching windows.
- The INPUT and OUTPUT panels for each node show you, with the replay's pinned data, exactly what went into and came out of that node in the execution you're reproducing.
Remember the restriction: inside an n8n 2.0 Code node there's no HTTP and no filesystem access; on Cloud, only crypto and moment. For tracing you need none of that — just reading values and returning them — so the restriction doesn't get in your way.
Worked example: hunting Cumbre's double refund
Let's reproduce and trace the complete bug. The reported symptom: "Luna Coffee's order ORD-3180 received two refunds on Tuesday." In the dead-letter queue (lesson 5) you have the suspicious execution's execution.id stored. Let's start.
Phase 1 — Load the recording.
Where you stand: you have the incident's execution.id, taken from the dead_letter table or the executions list.
Step 1.1. Open the issue-refund workflow and go to its executions list. Find the incident's execution by its id.
Step 1.2. Choose to debug it in the editor (check the exact button label in your version).
What to expect. n8n copies that execution's data into your editor and pins it to the first node. You'll see the first node with an indicator that its data is "pinned" — frozen. From now on, running the workflow uses that data, not a new event. You now have the recording loaded.
Phase 2 — Disconnect the real effect before touching anything.
Where you stand: with the recording loaded, but the workflow is still pointed at the real payments API. If you run it as-is, you issue real refunds.
Step 2.1. Before executing anything, disable or replace the HTTP Request node that calls the payments API — the one that issues the refund. The cleanest way for tracing is to temporarily swap it out for a node that just returns a simulated response, so the flow continues but without calling out to the real world.
What to expect. Now you can run the replay as many times as you want with no risk of issuing a single real refund. This step isn't optional: reproducing a duplicate bug against the real API creates real duplicates.
Phase 3 — Trace the idempotency key.
Where you stand: recording loaded, real effect disconnected, ready to observe.
Step 3.1. In the Code node that computes the idempotency key, temporarily add the value to the output so you can see it in the panel:
// ============================================================
// Node: Code — "Build refund key" (with temporary tracing)
// Mode: Run Once for Each Item
//
// _trace_key is added to the output ONLY for debugging; removed after.
// ============================================================
const item = $json;
// The key should be derived from stable data of the order.
const refundKey = `refund:${item.order_id}`;
return {
json: {
...item,
idempotency_key: refundKey,
_trace_key: refundKey, // ← temporary: to read it in the OUTPUT panel
},
};
Step 3.2. Run the step and read the OUTPUT panel. Note the value of _trace_key.
Step 3.3. Go to the node that checks the ledger to see whether this order already has a refund — the dedup check. Look at its INPUT panel (what key arrived) and its OUTPUT (what the ledger answered).
What to expect, and here's the bug. With the incident's real data loaded, you're going to see that in this execution the ledger check node answered "no previous refund exists" — and that's why the flow carried on and issued the refund. So far, correct. The problem isn't inside this execution: it's that there were two nearly simultaneous executions, both of which read the ledger before either had written to it. It's Module 2's "check then act" trap. By tracing the key, you confirm the key was computed correctly — refund:ORD-3180 in both executions, identical — and that the failure wasn't in the key but in the timing of the check: both asked "does this already exist?" before either had answered "yes, I did it."
Phase 4 — Confirm the hypothesis with the sibling execution.
Where you stand: you suspect a race condition between two executions.
Step 4.1. In the executions list, find the other ORD-3180 execution from Tuesday — the duplicate's sibling. Compare their timestamps: if both started with a difference of milliseconds, the race hypothesis is confirmed.
Step 4.2. Load that execution too and trace its key: you're going to find the same refund:ORD-3180, and the same "no previous refund exists" in its ledger check. Two executions, same key, both seeing an empty ledger, both issuing. The bug is now reproduced and understood.
Phase 5 — Verify the fix.
Where you stand: you understand the bug — "check then act" with no atomic protection — and you know Module 2's fix: the idempotency key has to be enforced with an atomic conditional write in the ledger (an INSERT with a uniqueness constraint that fails if the key already exists), not with a "read then decide." That way, out of two simultaneous executions, only one manages to insert the key; the other collides with the constraint and stops without issuing.
Step 5.1. Apply the fix in the workflow (Module 4's INSERT ... ON CONFLICT or equivalent), save it.
Step 5.2. From the executions list, use Retry with currently saved workflow on the incident's execution: same data that failed, running through your corrected version.
What to expect. With the fix, the execution that used to issue a refund now, when it tries to insert the key its sibling already inserted, collides with the uniqueness constraint and stops before the effect. The duplicate doesn't happen. You just verified the fix against the exact data that broke it, not against new orders with your fingers crossed. That's debugging with method.
Phase 6 — Clean up.
Step 6.1. Remove the temporary _trace_key from the Code node. Reconnect the real effect node you disconnected in phase 2. Publish.
Replay's limits: what it can't reproduce
For honesty, it's worth knowing what replay doesn't give you, because treating it as omniscient leads to false conclusions.
Replay reproduces an execution's data, but it doesn't reproduce the exact timing between different executions. Here's the race condition bug's subtlety: the duplicate was born because two executions ran almost at the same time and stepped on each other. When you load one of those executions with Debug in editor, you reproduce it alone, in your editor, with no sibling running in parallel. That is: replay lets you see perfectly what each execution did separately — and with that you confirm both computed the same key and both saw an empty ledger — but it doesn't recreate the exact instant the two overlapped. You deduce the race by comparing the two recordings and their start times, you don't observe it live.
This isn't a flaw; it's the nature of race conditions. Replay's value here isn't reproducing the collision, but giving you each participant's precise data so you can understand that they collided. That's enough to diagnose and fix, because the fix — the atomic conditional write — doesn't depend on recreating the timing, but on making the timing stop mattering: with a uniqueness constraint, it doesn't matter how close together the two executions run, only one wins.
The second limit: replay uses the recorded data, but the external state might have changed. If the original execution checked the ledger and it's changed since then — because other orders got processed — a Retry with currently saved workflow that checks the ledger again will see the current state, not that Tuesday's. That's why, to trace an old execution's logic, Debug in editor with pinned data is more faithful than a Retry that touches the world again. Keep in mind which parts of your replay use frozen data and which parts check something again that may have moved.
Why replay closes the module
It's worth seeing how this lesson ties everything before it together, because it's no accident it comes almost at the end.
Every piece of the module left a trace that replay makes use of. Lesson 2's retries get logged in the execution history — execution.retryOf tells you what was a retry of what. Lesson 3's compensations leave their mark in the ledger. Lesson 4's alert policy is what warned you there was a duplicate to investigate. And lesson 5's dead-letter queue stored the execution.id you used to open replay. Replay isn't an isolated tool: it's the one that reads everything the other pieces wrote, to reconstruct a failure's story.
And there's a nice symmetry with how we opened the module. The first lesson promised that by the end you'd be able to "reproduce a duplicate bug with the replay engine." You just did it, and the reason you could is that the whole system was designed, since Module 2, to leave stable traces: keys derived from data that doesn't change, a ledger that records the truth, a queue that doesn't lose context. A system that leaves no traces can't be debugged. Replay is the reward for having designed with discipline from the start.
Common mistakes
Reproducing the bug against the real API and creating real duplicates (practical). What happens: the double-refund execution gets loaded into the editor and run to investigate, with the workflow still pointed at the real payments API. The replay issues real refunds — and since you're running it several times to trace, you issue several. You just turned one incident into three. Why it happens: in debug mode it feels like you're "just looking," and it's easy to forget that running re-executes the effects. How to detect it: before running a replay, ask yourself "if this workflow runs, is it going to touch the real world?" If it touches money, emails, or any external effect, the answer has to be no. How to fix it: disconnect or simulate the real effect node before the replay's first run, as in phase 2 of the example. Reproducing a duplicate bug without disconnecting the effect is the recipe for multiplying it.
Verifying the fix with new orders instead of with the execution that failed (conceptual). What happens: someone believes they understand the bug, changes something, and verifies it by processing new orders — which come out fine. Since the bug was intermittent, coming out fine proves nothing: maybe the triggering condition just didn't happen. It gets declared resolved, and weeks later it comes back. Why it happens: processing new orders is the most natural thing to do and the bug seems to go away. How to detect it: if your verification doesn't use exactly the data that produced the failure, you aren't verifying the bug's fix; you're verifying the system works in the easy case. How to fix it: use Retry with currently saved workflow on the incident's execution. The same data that used to break it, running through your corrected version, is the only proof that counts for an intermittent bug.
Tracing without pinning the data, firing new events each time (practical). What happens: instead of loading the execution with Debug in editor, the webhook gets fired by hand over and over hoping the bug shows up. Since it's intermittent, it almost never appears, and when it does, the data is no longer the same as next time, so you can't compare. Why it happens: firing the trigger is the usual reflex, and the idea of "loading an old execution" isn't obvious. How to detect it: if you're waiting for the bug to "show up again" instead of reproducing one that already showed up, you're debugging with luck. How to fix it: load the incident's specific execution with Debug in editor, which pins the exact data to the first node. With the data pinned, the bug stops depending on chance and you can run and trace it as many times as you need.
Exercises
Exercise 1 — Order the method. These six steps are out of order. Put them in the correct order to reproduce and fix the double-refund bug without causing damage:
(a) Apply the fix (atomic conditional write in the ledger) and save. (b) Load the incident's execution with Debug in editor. (c) Retry with currently saved workflow on the incident's execution. (d) Disconnect or simulate the real effect node. (e) Trace the idempotency key and confirm the race condition. (f) Remove the temporary tracing and reconnect the real effect.
See solution
The correct order: (b) → (d) → (e) → (a) → (c) → (f).
- (b) Load the execution with Debug in editor: you pin the incident's exact data to the first node.
- (d) Disconnect the real effect: before running anything, so you don't issue real refunds while reproducing.
- (e) Trace the key: you follow the value step by step and confirm the key was fine but two simultaneous executions read an empty ledger — the race condition.
- (a) Apply the fix: the atomic conditional write that makes only one of two executions succeed at inserting the key.
- (c) Verify with Retry with currently saved workflow: the same data that used to fail, through the corrected version; you confirm the duplicate no longer happens.
- (f) Clean up: you remove the temporary tracing and reconnect the real effect before publishing.
The order's critical point is that (d) comes before any execution, and (c) comes after (a) — verifying before fixing wouldn't make sense.
Why this works: the method has an intertwined safety logic and diagnostic logic. First you protect yourself (b, d), then you understand (e), then you fix (a), then you verify against the real data (c), and finally you clean up (f). Skipping the order — fixing before understanding, or running before disconnecting — is where debugging goes wrong.
Exercise 2 — Debug in editor or Retry. For each situation, say whether you'd use Debug in editor, Retry with original workflow, or Retry with currently saved workflow, and why:
(a) You want to understand why an execution failed, tracing its data step by step. (b) You already fixed the workflow and want to confirm the data that used to fail now passes fine. (c) You want to confirm you're reproducing the original behavior before touching anything.
See solution
(a) Debug in editor. It loads the execution's data into the editor and pins it to the first node, which is what lets you trace node by node. It's the understanding tool.
(b) Retry with currently saved workflow. It runs the old execution's data through your current workflow — the one you've already fixed. It's the only way to verify a fix against the exact data that broke it. It's the verifying the fix tool.
(c) Retry with original workflow. It re-executes with the workflow as it was, without your changes, to confirm the original clean behavior. It's the confirming the baseline tool.
The underlying distinction: Debug in editor is for tracing inside the editor with the data pinned; the two Retry variants relaunch the whole execution, differing in which version of the workflow they use — the old one (original) or your current one (currently saved).
Why this works: choosing the right tool based on what you want to achieve — understand, verify the fix, or confirm the baseline — is what makes debugging efficient. Using Retry when you wanted to trace, or Debug when you wanted to verify, makes you spin your wheels.
Exercise 3 — Trace it yourself. You're given this Code node that computes a refund's idempotency key, and you're told "sometimes it generates different keys for the same order, and that's why a duplicate sometimes slips through." Find the bug by mentally tracing the key, and say how you'd confirm it with replay.
// Node: Code — "Build refund key" (with a bug)
const item = $json;
const refundKey = `refund:${item.order_id}:${Date.now()}`;
return { json: { ...item, idempotency_key: refundKey } };
See solution
The bug is in Date.now(). The key gets built with refund:${item.order_id}:${Date.now()}, and Date.now() returns the current instant in milliseconds — a value that changes on every execution. That means two executions of the same order ORD-3180, running at different moments, generate different keys: refund:ORD-3180:1719...801 and refund:ORD-3180:1719...847. Since the keys are different, the payments API sees them as two different refunds and issues both. The idempotency protection collapses because the key isn't stable.
It's exactly the mistake Module 2 warned about, and that we reviewed in lesson 5: the key must be derived from data that doesn't change between retries or between executions — the order_id, the operation type — never from the time or a random value. The fix is removing Date.now(): const refundKey = `refund:${item.order_id}`;.
How you'd confirm it with replay: you load two different executions of the same ORD-3180 with Debug in editor, and trace the key in each one by temporarily adding it to the output. You'll see the order_id is identical in both but the full key differs in the timestamp part. That confirms the failure isn't in the order or the ledger, but in the key incorporating a changing value. Then you apply the fix and verify with Retry with currently saved workflow that both executions now produce the same key.
Why this works: this is the most common idempotency bug there is, and it's subtle because the code "looks" correct — it even generates a key with the order_id inside it. Tracing the key and seeing it differ between two executions of the same order is what makes it obvious. Without replay, you'd keep guessing why it duplicates "sometimes."
Summary and next step
In this lesson you took the worst kind of bug — the intermittent one, that shows up one time in a hundred and vanishes when you go looking for it — and learned to turn it into a deterministic one. You saw why these bugs break the normal debugging cycle: they can't be reproduced at will, they almost always come from a race condition or from timing-dependent state, and that's why you need the black box. You learned n8n 2.0's debugging engine: Debug in editor, which copies a real execution's data and pins it to the first node so you reproduce the recording instead of waiting for the bug to come back; and the two Retry variants — with the original workflow to confirm the baseline, with the currently saved one to verify a fix. You traced Cumbre's double refund's idempotency key step by step, with the discipline of disconnecting the real effect before touching anything, and discovered the failure was Module 2's "check then act" trap — two simultaneous executions reading an empty ledger. And you saw that replay closes the module because it reads the traces every other piece left behind.
Before moving on you should be able to: load a past execution with Debug in editor and explain what it does with the data; choose between Debug, Retry original, and Retry currently saved depending on whether you want to understand, confirm the baseline, or verify a fix; and trace a variable inside a Code node to find where it stops being what you expected — including the classic Date.now() in an idempotency key.
You now have the module's five technical pieces: retries, compensations, alerts, error workflows with a dead-letter queue, and replay. What's left before the capstone is an honest conversation about the limits. Everything you built runs on a self-hosted Community instance at zero cost — and that's genuinely remarkable — but there's a boundary where the correctness you designed runs into operational needs Community doesn't cover: storing the ledger's secrets in an external manager, testing contracts in separate environments, versioning workflows with Git for a team. Lesson 7 draws that line precisely: what Community solves at $0, what requires paying, and why the rest lives in n8n-production-maintenance-guide and not here.
Resources
- Debug and re-run past executions — n8n Docs — Debug in editor's official page: how it loads a past execution's data and pins it to the first node, the basis of this lesson's whole method.
- Executions — n8n Docs — the executions list from which you load an execution into the editor and launch Retry with the original or currently saved workflow.
- Error handling — n8n Docs — includes execution retries and how
retryOfrelates a retry to its original execution in the history. - Code node — n8n Docs — the node where you trace the idempotency key, with its 2.0 limits (no HTTP or filesystem; on Cloud only
cryptoandmoment). - Workflow settings — n8n Docs — where you turn on saving executions (including failed ones), without which there's no recording to reproduce.