Skip to content
Back to Build Tutorials

Debug Strategy

Adopt a reproducible method (bisect, isolate, hypothesis) instead of trial-and-error tweaking.

Why

Random tweaks — commenting lines, swapping values, restarting the server — feel productive but rarely find the root cause. They mask the bug by coincidence (it disappears on a retry) or move it elsewhere (the symptom changes but the cause remains). A reproducible method turns debugging from superstition into engineering: you form a hypothesis, design an experiment that can falsify it, and keep a log so you never re-investigate the same dead end. The single biggest time-saver is git bisect — it finds the exact commit that introduced the bug in log(N) steps instead of reading diffs. Teams without a method spend 3x longer on hard bugs and often 'fix' them in a way that regresses two weeks later.

When

Before the first hard bug. Takes 30 min to learn the method, saves hours per bug. Skip never.

How

1. Reproduce reliably — a bug you can't reproduce is one you can't fix
   - Write a failing test that triggers it; if you can't, narrow the steps until you can
   - Record the exact env (node version, env vars, DB state) so it's not "works on my machine"

2. Form a hypothesis and write it down before acting
   - "The null ref happens because the user was created before the profile migration ran"
   - A written hypothesis is falsifiable; a vague hunch is not

3. Isolate the variable — change one thing at a time
   - Disable half the middleware; if the bug persists, the cause is in the other half
   - Use git bisect to find the introducing commit: git bisect start && git bisect bad HEAD && git bisect good v1.0

4. Inspect, don't assume — read the actual values
   - console.log / debugger at the boundary, not deep inside
   - Check the DB row, the network response, the env var — the bug is usually where the data crosses a boundary

5. Fix the cause, not the symptom — then verify the test passes and stays green
   - Add a regression test so this bug can never return
   - If the fix is "add a null check", ask why the value was null and fix that

Example

# Reproduce: a failing test that triggers the bug
test("user created before profile migration does not crash", () => {
  const user = factory.user({ createdAt: "2024-01-01", profileId: null });
  expect(() => renderProfile(user)).not.toThrow();
});

# Bisect to find the introducing commit
git bisect start
git bisect bad HEAD              # current is broken
git bisect good v1.4.0           # v1.4.0 was fine
# git checks out commits; you mark good/bad; it narrows in ~7 steps for 100 commits

# Hypothesis log (keep a DEBUG.md while investigating)
# 2024-03-15 14:30  H1: null profileId — FALSIFIED, guard exists since v1.2
# 2024-03-15 14:45  H2: race between user.create and profile.create — CONFIRMED

Common Problems

  • ⚠Changing 3 things at once, the bug disappears, and you have no idea which change fixed it — so it regresses later.
  • ⚠Debugging in dev with hot reload, where the bug doesn't reproduce because the module state is stale — you waste an hour on a phantom.
  • ⚠Reading the code instead of the runtime values — the bug is almost always in what the code actually does, not what it appears to say.
  • ⚠Skipping the regression test, so the same bug is re-introduced in a refactor 3 months later.

Best Practices

  • ✓Keep a DEBUG.md per investigation: timestamp, hypothesis, experiment, result — it turns a 4-hour hunt into a 30-minute read when the bug returns.
  • ✓Use git bisect early; it's free and finds the exact commit in logarithmic time, no reading required.
  • ✓Add a regression test in the same PR as the fix so the link is permanent and reviewable.
  • ✓When the fix is a guard (null check, try/catch), spend 10 more minutes finding why the bad value reached the guard — the guard hides the real bug.