Building with AI · Guide 22 of 35

Using AI to Debug Code

Turn a reproducible failure into a focused investigation, test hypotheses and verify the smallest justified correction.

IntermediateAbout 16 min readPractical exercise

Before you start: JavaScript functions, promises and async/await. Reading time is an estimate; allow extra time for practice.

What you’ll learn

  • Capture a repeatable failure and separate observed evidence from assumptions.
  • Sanitise debugging material before sharing it with an AI assistant.
  • Ask for competing hypotheses and choose checks that distinguish them.
  • Reproduce an asynchronous race and evaluate a focused AI-generated fix.
  • Write a regression check and verify the result under different response timings.
On this page

Capture what actually failed

Imagine a profile picker with two test profiles, Alpha and Beta. Selecting a profile starts a request and displays its details. If you select Alpha and then quickly select Beta, the picker sometimes still says Beta while the details switch back to Alpha. You expect the details to match the latest selection. There is no console exception: the wrong result is the error.

We will trace this failure from observation to a regression test. In the worked example, Alpha’s older request finishes after Beta’s newer request and overwrites the display. At the start of a real investigation, that explanation is only a hypothesis. First establish what happened.

Record the page, browser and application version. Open DevTools → Network, select Alpha, immediately select Beta, and leave the picker on Beta until both requests finish. Record each requested profile, response status, returned profile name and completion order. Capture the final selection and displayed name. A 200 response alone does not prove that the correct data was rendered.

Repeat the steps and note when they fail. Keep the relevant request handler and render code with this evidence. Do not begin by clearing caches or rewriting the component: either action may remove the conditions you need to understand.

Protect private information before sharing evidence

Logs and network exports can contain passwords, Authorization headers, access tokens, session cookies, signed URLs and personal records. An access token or session cookie may let someone act as the account holder. A screenshot can also expose data in an open response panel. Inspect material locally before sending it to an AI assistant.

Use test profiles and replace identifying values with consistent placeholders such as Alpha and Beta. Remove credentials, cookies, tokens, private URLs and unnecessary response fields. Preserve the timing, status codes and relationships between requests that explain the bug. Share the smallest relevant code extract rather than a full repository or raw network export.

Check your organisation’s rules and the assistant’s data-handling settings before sharing confidential code or records. A prompt saying “keep this secret” is not a protection mechanism. If a credential has already been exposed, revoke or rotate it through the appropriate account process; deleting the chat alone is not enough.

Give the assistant evidence and a bounded task

Ask for an investigation before asking for a patch. The sample below includes the observed mismatch, repeatable steps and a sanitised trace. The timings are illustrative; replace them and the code placeholder with your own observations. Explicitly state when you have no console error instead of inventing an exception.

Example prompt: investigate the stale profile display
I am debugging a JavaScript profile picker.
Observed error: Beta remains selected, but Alpha details appear.
Expected: details must match the latest selected profile.
No exception appears in the console.

Reproduction:
1. Open the picker using two test profiles.
2. Select Alpha, then immediately select Beta.
3. Wait for both requests; the display sometimes ends on Alpha.

Sanitised evidence from one failing run:
0 ms: request 1 starts for Alpha.
5 ms: request 2 starts for Beta.
35 ms: request 2 returns 200 with { name: "Beta" }.
120 ms: request 1 returns 200 with { name: "Alpha" }.
Final selection: Beta. Final displayed name: Alpha.
Relevant handler and render code: [paste a sanitised extract].

Give three hypotheses in a Hypothesis / Predicts / Check table.
For each, give one test that distinguishes it from the others.
Separate observations from assumptions; identify missing evidence.
Do not propose fixes yet.

The assistant can interpret supplied evidence and suggest checks. It cannot confirm unseen requests or inspect code you have not provided.

Test competing explanations

A hypothesis is a possible explanation with a prediction you can check. Start by comparing the requested profile with the response body, then compare response completion with state updates. In our trace, both bodies match their requests, which weakens the wrong-response hypothesis. It does not yet prove which code changed the display.

Use a breakpoint or a temporary, sanitised log at the display update. If request 2 writes Beta and request 1 then writes Alpha while Beta is still selected, the evidence supports a response-order race. If that sequence is absent, investigate the other explanations rather than forcing the race diagnosis.

Three explanations for the stale profile display
HypothesisPredictsCheck
The server or HTTP cache returns the wrong profile.The response to a Beta request already contains Alpha.Compare each request’s profile ID with its response body. If the body is wrong, investigate server lookup and cache keys.
The view reads stale client state.The latest response and stored profile are Beta, but the rendered details remain Alpha.Inspect the value written by the handler and the value read during rendering. Check state bindings and any memoised value.
An older response overwrites the newer selection.Beta renders first; Alpha renders when its earlier request finishes later.Control request completion order and log each display update with a request ID. Check whether old requests can still write to the current view.

Reproduce two responses arriving out of order

A race condition makes the result depend on the order in which asynchronous operations complete. JavaScript does not need two functions running simultaneously for this bug: starting a request and processing its response happen at different times. An earlier request can finish later.

Run this complete JavaScript example in a browser console or Node.js. fakeFetch simulates a request using a timer; it makes no network calls and needs no credentials. displayedName stands in for the text your UI would render. Both calls start without waiting for the other, just as two quick selections would.

Runnable JavaScript: an older response wins incorrectly
let displayedName = '';

function fakeFetch(name, delayMs) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ name }), delayMs);
  });
}

async function showProfile(name, delayMs) {
  const profile = await fakeFetch(name, delayMs);
  displayedName = profile.name;
  console.log('Showing:', displayedName);
}

async function demo() {
  const first = showProfile('Alpha', 80);
  const second = showProfile('Beta', 10); // Latest selection
  await Promise.all([first, second]);
  console.log('Final:', displayedName);
}

demo();

Expected output for these simulated delays: “Showing: Beta”, then “Showing: Alpha”, then “Final: Alpha”. Each request succeeds, but every response is allowed to update the same display. Promise.all waits for both; it does not control which response writes last. Timer delays simulate an ordering and are not exact real-network timings.

Make the smallest justified correction

Once you have observed the stale write, ask the assistant for a small change that enforces this rule: only the latest selection may update the display. Review its proposal against that rule. Adding a sleep, swallowing errors or disabling quick selections does not establish that stale responses are harmless.

One solution assigns an increasing ID to each request. Replace showProfile in the complete example with the code below, including the counter above it. Keep fakeFetch and demo unchanged, and run the combined example in a fresh console context or script. The counter must survive across calls for this picker; do not reset it inside showProfile.

Replacement JavaScript: ignore a stale response
let latestRequest = 0;

async function showProfile(name, delayMs) {
  const requestId = ++latestRequest;
  const profile = await fakeFetch(name, delayMs);
  if (requestId !== latestRequest) return;
  displayedName = profile.name;
  console.log('Showing:', displayedName);
}

Now the output is “Showing: Beta”, then “Final: Beta”. Alpha still finishes, but its ID is no longer current, so it cannot change the display. This protects a read-only display; it neither cancels network work nor orders server-side writes. In a real component, keep the counter per instance and guard stale error/loading updates and updates after unmount as well.

Guard errors and loading state too

A stale failure can replace a newer success with an irrelevant error, just as a stale success can replace the displayed profile. Use the same request ID in try, catch and finally: only the current request may write data, show an error or finish loading.

Run this standalone example in a fresh browser console or Node.js script. The final argument makes fakeFetch fail. Beta is the latest selection and fails first; Alpha succeeds later but must not replace Beta’s error. The state object stands in for UI state, and errors are caught inside showProfile.

Runnable JavaScript: handle success, failure and loading
let latestRequest = 0;
const state = { name: '', error: '', loading: false };

function fakeFetch(name, delayMs, fail = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (fail) reject(new Error('Could not load ' + name));
      else resolve({ name });
    }, delayMs);
  });
}

async function showProfile(name, delayMs, fail = false) {
  const requestId = ++latestRequest;
  state.name = '';
  state.error = '';
  state.loading = true;
  try {
    const profile = await fakeFetch(name, delayMs, fail);
    if (requestId !== latestRequest) return;
    state.name = profile.name;
  } catch (error) {
    if (requestId !== latestRequest) return;
    state.error = error.message;
  } finally {
    if (requestId === latestRequest) state.loading = false;
  }
}

async function demo() {
  await Promise.all([
    showProfile('Alpha', 80),
    showProfile('Beta', 10, true),
  ]);
  console.log(JSON.stringify(state));
}

demo().catch(console.error);

Expected: {"name":"","error":"Could not load Beta","loading":false}. Now make Alpha fail and Beta succeed: the final name must be Beta with no error. Try both completion orders. While Beta is pending, Alpha finishing must not clear loading. This mock always rejects with an Error; real integrations should normalise other rejection values and use a safe user-facing message.

Ignoring a response does not cancel its request

The request ID protects the interface but leaves the underlying work running. AbortController can cancel supported fetch requests: pass its signal to fetch and call abort() when the request is no longer needed. Catch cancellation as well as other failures. Identity checks remain useful alongside cancellation because older asynchronous work may already have progressed or may not support cancellation.

In a component, keep the ID per instance. On unmount, invalidate the current ID and cancel supported requests in cleanup so pending work cannot update a removed view. The console examples have no component lifecycle; verify that cleanup in the actual application.

Turn the reproduction into a regression check

A regression test preserves the expected behaviour so the same failure is caught if it returns. For this picker, assert that the final displayed profile is Beta after selecting Alpha and then Beta, even when Alpha resolves last. Confirm that this assertion fails with the original implementation and passes with the guard; otherwise the test may never have exercised the bug.

Return to the earlier success-only example. Replace the final console.log in demo with the assertion below. Run it first with the original showProfile and then with the request-ID replacement, in fresh script runs. The original run should reject with the assertion error; the fixed run should finish without that error. Use demo().catch(console.error) to report that intentional failure without an unhandled rejection. This is a teaching check, not a complete application test suite.

Regression assertion: replace the final log inside demo
if (displayedName !== 'Beta') {
  throw new Error('Expected Beta, got ' + displayedName);
}

In an automated test runner, await demo() so a rejected promise fails the test. Use fake timers or manually controlled promises to resolve Beta and then Alpha deterministically, rather than relying on a real network race. Also reverse completion order: Beta must remain the final result in both cases.

Close the investigation with evidence

Apply the reviewed change to the real picker, then repeat Alpha → Beta rapidly several times and wait for all responses each time. Use DevTools network throttling to make overlap easier to observe, but do not assume throttling guarantees a particular order. Use request interception or controlled test promises to force the older response to arrive last and then test the opposite order.

Run the new regression test and the existing tests for selection, loading and error handling. Inspect the actual output and exit status. A single passing run does not rule out an intermittent failure. An assistant saying it ran tests is not a substitute for inspecting execution evidence. Repeated manual checks help investigate intermittent behaviour; deterministic tests make the failure reproducible.

Finish with a short record: the failing steps, the observed stale write, why the guard prevents it, the checks performed and any remaining limitations. You can now explain the correction from evidence rather than relying on the assistant’s confidence.

  • Single profile: select Alpha and confirm its details appear after loading.
  • Same profile twice: select Beta twice and check that the older request cannot overwrite the newer result.
  • Three rapid selections: select Alpha, Beta and Gamma; only the latest selection may determine the final display.
  • Failed latest request: make Beta fail; show its error or defined empty state. A late Alpha success or failure must not replace it.
  • Loading indicators: an older completion must not stop the latest request’s spinner. The latest success or failure must stop it.
  • Component unmounting: leave the page with a request pending and confirm cleanup prevents later state updates.
Put it into practice

Try it yourself

Extend the success-only demo with a third selection, Gamma, using a 30 ms delay. What should the final display be? Update the regression assertion, run the original and guarded versions, and explain which responses may update the display.

Show hint

Start all three calls without awaiting between them and include all three promises in Promise.all. Write down selection order and completion order separately. Which order should determine the final display? Predict each implementation’s result before running it.

Optional practice

Check your understanding

5 questions to revisit the ideas. No score is saved, and you can retry. This is separate from Skill Quiz; it does not award XP or unlock guides.

1. Beta remains selected, but Alpha details appear after both requests succeed. What is an observation at this stage?
2. Which debugging evidence should be shared with an assistant?
3. What does the increasing request-ID guard establish in this read-only example?
4. Why must catch and finally check the request ID too?
5. Which evidence most directly supports the race fix?

Sources & further reading

Primary references for the concepts in this guide. Examples and exercises are illustrative; verify provider-specific behaviour against the version you use.