A user selects a new result, sees the correct content briefly, then watches it change back to an older selection. No exception appears in the Console. Both asynchronous operations succeeded. The bug is in which completion gets to update the interface.
Chrome DevTools cannot rewind a running JavaScript application to an arbitrary previous state. It can, however, record actions, pause execution before a suspicious write, and expose the values that explain it. That is the useful part of the "time machine" metaphor: reconstruct what happened rather than guessing from the final screen.
This walkthrough uses Chrome DevTools and a deliberately small, local example. The outcome is a method you can reuse for stale search results, profile panels, and overlapping requests. It is not a Network or Performance panel tour; for timing analysis of resource loading and main-thread work, see the separate Chrome DevTools Network and Performance guide.
Reproduce a result that arrives out of order
Save this as devtools-race.html and open it in Chrome. The timers stand in for operations with different completion times, without depending on a server or an internet connection.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Out-of-order completion demo</title>
<button type="button" id="slow">Load slow result</button>
<button type="button" id="fast">Load fast result</button>
<p id="result" role="status">Nothing loaded</p>
<script>
const result = document.querySelector("#result");
let latestRequestId = 0;
async function load(label, delayMs) {
const requestId = ++latestRequestId;
const response = await new Promise(resolve => {
setTimeout(() => resolve(label), delayMs);
});
result.textContent = `Showing: ${response}`;
}
document.querySelector("#slow").addEventListener("click", () => load("slow", 450));
document.querySelector("#fast").addEventListener("click", () => load("fast", 40));
</script>
</html>
Click Load slow result, then immediately Load fast result. The fast operation completes first and displays Showing: fast. Later, the slow operation finishes and overwrites the UI with Showing: slow.
The timer values make the race easy to trigger, but they do not represent measured network latency. The variable requestId records the order in which actions started. The code never checks that order when each action finishes. Asynchronous completion order is not guaranteed to match initiation order, an important distinction in the JavaScript event loop.
The invariant we want is simple: only the most recently requested result may update this element.
Locate the stale write without editing the application
Open DevTools and switch to Sources. Find the demo document in the Page file tree, then locate the line assigning result.textContent. If source files are bundled in a real application, source maps may help you reach the authored code; a missing or inaccurate source map can make breakpoint locations harder to match.
Watch both completions with a logpoint
Right-click the line number beside the assignment and choose Add logpoint. Enter the following as the logpoint expression, without console.log(...):
"completion", requestId, latestRequestId, response
Run the two-click sequence again. The logpoint reports the completion values without pausing the page or adding a logging statement to the source file. You should see the fast completion while its request ID is current, followed by the slow completion with an older ID.
Chrome documents logpoints as accepting the same arguments as console.log, not an interpolation syntax such as User: {user}. A logpoint can still add execution overhead, and logging private form data, tokens, or personal information is unsafe. Use sanitized values in any environment with real users. See Chrome's breakpoint reference.
Pause only when an old completion is about to write
Replace the logpoint with a conditional breakpoint on that same assignment line:
requestId !== latestRequestId
Now repeat the slow-then-fast sequence. DevTools should pause just before the outdated completion changes the paragraph. Inspect Scope: requestId belongs to the older operation while latestRequestId reflects the newer click. Step over the assignment to see the stale write, or resume execution.
Pausing execution changes timing, so the breakpoint is evidence of the values at the paused line, not proof that every real-world race will reproduce in exactly the same order under a debugger. Establish the failure without a pause first, then use the pause to locate its cause.
When the offending line is unknown, start from the symptom instead. In Elements, right-click the <p id="result"> node and select Break on > Subtree modifications. The code changes the paragraph's text content, so a DOM change breakpoint can lead you to the responsible write. Attribute modifications would be the wrong type for this particular example: no attribute changes. See Chrome's DOM breakpoint behavior.
This gives you two directions of investigation:
- From the code: a conditional breakpoint answers which values made this assignment wrong?
- From the page: a DOM breakpoint answers which script changed this element?
Neither requires a speculative rewrite before you have identified the mutation.
Use Recorder to repeat the actions, not to restore history
Open More tools > Recorder in Chrome DevTools, start a recording, click the slow button and then the fast button, and stop the recording. Replay it and inspect the displayed result. The Recorder can save and replay interaction steps; it also lets you slow a replay, pause at steps, or inspect and edit recorded selectors. These capabilities are described in the official Recorder tutorial.
A recording is not a snapshot of the JavaScript heap, timers, server responses, or DOM history. It does not restore the exact environment that existed during the first run. In particular, replay pacing may differ from how quickly you clicked the buttons, and a real service may respond in a different order. A passing replay does not disprove an intermittent race.
For this demo, the simplest repeatable check is still two clicks before the slow timer finishes. For a real bug, record the actions, preserve the relevant request and response details separately, and make the asynchronous conditions controllable in an automated test. If the failure happens only on a CI runner, a Playwright trace for the failed test is a more appropriate debugging artifact than relying on a DevTools Recorder replay alone.
Before sharing a recording, review it for sensitive values and environment-specific URLs.
Fix the invariant, then try to violate it
Place this guard immediately after the await and before the assignment in load:
if (requestId !== latestRequestId) return;
result.textContent = `Showing: ${response}`;
The slow operation can still finish, but it cannot replace a result requested more recently. After making the change, repeat the two clicks: the fast result should remain displayed after the slow timer completes.
A useful regression check has two cases, not one:
- Click slow, then fast in quick succession. After both timers finish, expect
Showing: fast. - Reload, click only slow, and wait. Expect
Showing: slow.
The second case matters: a "fix" that simply prevents old or slow operations from rendering would also conceal valid results.
For a real fetch request, AbortController can help cancel superseded work when cancellation is supported, but cancellation and ignoring stale completions solve different parts of the problem. A request token is still useful for operations that cannot be aborted, or for guarding a state update after other asynchronous work. The toy timers here deliberately avoid claiming to demonstrate network cancellation.
Match the debugging tool to the actual symptom
| What you know | Where to start | What it can establish |
|---|---|---|
| A specific line writes the wrong state | Sources conditional breakpoint | The local values before the write |
| The UI changes but the writer is unknown | Elements DOM change breakpoint | The code responsible for that DOM mutation |
| A sequence of clicks matters | Recorder | A reusable sequence of user actions, not an exact state snapshot |
| An exception is thrown | Sources exception breakpoints | Where a caught or uncaught exception occurs |
| A page feels progressively slower | Performance monitor, then Performance or Memory | A pattern worth profiling, not a confirmed root cause |
Chrome's Performance monitor shows live CPU, JavaScript heap, DOM-node and layout-related metrics. A spike might tell you where to investigate; it cannot, by itself, tell you that a particular function is responsible. For this race, no performance measurement is needed. See the Performance monitor documentation.
Likewise, an async call stack is only as complete as the available instrumentation and framework support. Don't interpret a missing earlier async frame as evidence that no earlier asynchronous work happened. Chrome explains those limitations in its JavaScript debugging reference.
The practical workflow is reproduce, observe the write, inspect the state, change one condition, and reproduce again. DevTools supplies a way to investigate events that already happened in your program's execution. The judgment about what those events mean still belongs to the developer.
