Debugging is a skill, not a talent
The developers who seem to fix bugs by instinct aren’t guessing — they’re running a disciplined process fast enough that it looks like intuition. The process is learnable. Once you have it, “I have no idea why this is broken” turns into “I know how to find out.”
The core method: form and test hypotheses
Every bug fix is the scientific method compressed:
- Observe — what actually happens, precisely?
- Hypothesize — what could cause that?
- Predict — if the hypothesis is true, what else must be true?
- Test — check that prediction
- Repeat — narrow until you find the cause
The discipline is in step 4. Most wasted debugging time comes from changing code to see if it helps, instead of testing a hypothesis about what’s wrong. Changing things at random occasionally works and teaches you nothing; it also introduces new bugs while you’re not looking.
Step zero: reproduce it reliably
You cannot fix what you cannot reproduce. Before anything else, find the exact steps that trigger the bug every time.
Bug report: "Checkout sometimes fails"
Not reproducible → not fixable. Narrow it:
- Which payment method?
- Logged in or guest?
- Specific items in cart?
- Only on mobile? Only on Safari?
- Only when the coupon field is used?
A bug that happens “sometimes” just has a condition you haven’t identified yet. “Sometimes” is a clue, not a dead end — it means something in the environment or input varies. Pin down the variable and “sometimes” becomes “always, when X.”
If it’s genuinely intermittent (race conditions, timing), add logging that captures the state each time it happens, then wait for enough occurrences to see the pattern.
Read the actual error message
It sounds obvious. It’s routinely skipped. The error message and stack trace usually contain the answer, or point directly at it.
TypeError: Cannot read properties of undefined (reading 'name')
at renderUser (UserCard.jsx:12:34)
at ...
This tells you: at UserCard.jsx line 12, something you expected to be an object was undefined, and you tried to read .name off it. You don’t need to guess where to look — the trace names the file and line. Start there. Read the whole trace, not just the first line; the chain shows how execution reached the failure.
Reduce to a minimal case
If the bug lives inside a 500-line function calling ten others, shrink the problem. Strip away everything that isn’t necessary to reproduce it. Often the act of reducing reveals the cause before you even finish.
// Original: bug somewhere in a complex flow
async function processOrder(order) {
const validated = await validate(order);
const priced = await calculatePricing(validated);
const taxed = await applyTaxes(priced);
const saved = await save(taxed);
await sendConfirmation(saved);
return saved;
}
// Reduce: which step breaks? Log between each, or comment out
// downstream steps until the error disappears. The last step you
// removed before it started working is where the bug lives.
This is the debugging equivalent of a controlled experiment: remove variables until only the cause remains.
Binary search your way to the cause
When the bug is somewhere in a large space — a long function, a range of commits, a big dataset — don’t scan linearly. Halve the search space each step.
In code: put a check at the midpoint. Is the state correct there? If yes, the bug is in the second half; if no, the first half. Repeat. Twenty steps can bisect a million lines.
In history: git bisect finds the commit that introduced a regression by binary-searching your commits.
git bisect start
git bisect bad # current commit is broken
git bisect good v1.2.0 # this old version worked
# git checks out the midpoint commit. Test it:
npm test
git bisect good # or 'bad' depending on result
# Repeat ~log2(N) times. Git names the exact breaking commit.
git bisect reset
Bisecting 1,000 commits takes about 10 tests instead of 1,000. When you know it worked last week and breaks today, this is the fastest route to the culprit.
Use a real debugger, not just print statements
console.log is fine for a quick look, but a debugger lets you pause execution and inspect everything at that moment — every variable, the call stack, the scope chain — without editing code and re-running.
function calculateTotal(items) {
debugger; // execution pauses here when dev tools are open
return items.reduce((sum, item) => sum + item.price, 0);
}
Or set breakpoints in your editor / browser dev tools with no code change at all. Key capabilities worth learning:
- Conditional breakpoints — pause only when
item.id === 42, instead of stepping through 10,000 iterations to reach the one that matters. - Watch expressions — track how a value changes as you step.
- Step over / into / out — walk execution at the granularity you need.
- Logpoints — print a value without editing source or stopping (Chrome/VS Code).
VS Code’s built-in debugger works for Node and browser code. Learning it is a few hours that pays back for your whole career.
When print debugging is right
Debuggers aren’t always the move. For async races, production issues, or code running somewhere you can’t attach a debugger, logging wins. Do it well:
// ❌ Useless — which one fired? what was the value?
console.log('here');
console.log(data);
// ✅ Labeled, structured, greppable
console.log('[checkout] pricing input:', JSON.stringify({ items, coupon }));
console.log('[checkout] pricing result:', total);
Label every log with where it came from and what it shows. console.table() for arrays of objects and console.dir(obj, { depth: null }) for deep nesting are underused. Remember to remove or gate debug logs before shipping — a lint rule (no-console) catches strays.
The “it worked yesterday” checklist
When something that worked stops working and you swear you changed nothing:
- You changed something. Check
git statusandgit diff. Something is different. - A dependency changed. Did an unpinned package update? Check your lockfile, run a clean install.
- The environment changed. New Node version? Env var changed? Different data in the database?
- State accumulated. Cache, stale build artifacts, a corrupt local database. Try a clean slate:
rm -rf node_modules dist && npm ci.
Nine times out of ten, “I didn’t change anything” means “I didn’t change anything on purpose.”
Rubber duck debugging really works
Explain the problem, out loud, line by line, to a rubber duck / coworker / patient houseplant. The act of articulating forces you to examine assumptions you’d been skipping over. The number of bugs solved mid-sentence — “…and then it passes the user ID, which is… oh. It’s not the ID, it’s the whole object.” — is genuinely high. The listener doesn’t need to understand a word; you’re debugging your own mental model by making it explicit.
Check your assumptions explicitly
Bugs hide in the gap between what you think is true and what is true. Make the assumption testable:
// You assume `user` is always populated here. Prove it.
console.assert(user != null, 'user was null in processPayment');
console.assert(user.id, 'user.id missing', user);
The classic assumptions worth checking: this array isn’t empty; this value is a number not a string; this async call finished before this line; this config loaded; this is the code that’s actually running (are you editing the file that’s deployed?). That last one has cost every developer at least one afternoon.
Fix the class, not just the instance
You found the null-pointer crash and added a guard. Before moving on, ask: why was it null? and where else could this happen?
// Instance fix — patches this one symptom
if (user) {
return user.name;
}
// Class fix — why was it null? If the API can return null,
// handle it at the boundary where data enters, so every
// consumer is protected, not just this call site.
function fetchUser(id) {
const user = await api.getUser(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user; // downstream code can now trust it
}
A guard that hides the symptom often just moves the crash somewhere else, later, harder to trace. Fixing the class of bug — validating at boundaries, correcting the type that was wrong, handling the state that shouldn’t exist — prevents the next five bugs of the same shape.
Know when to step away
Past a certain point, tired debugging produces worse changes than no changes. If you’ve been staring for an hour and hypotheses have turned into flailing, stop. Walk away. The solution arriving in the shower is a cliché because it’s real — your brain keeps working on it once you stop clenching. Come back and the bug you couldn’t see is often obvious in the first two minutes.
The summary
Debugging well is mostly refusing to guess. Reproduce it, read the error, form a hypothesis, test that — don’t change code hoping. Bisect large spaces instead of scanning them. Reach for a debugger, fall back to structured logging. And when you find the cause, fix the class of problem, not just today’s instance of it. Do this consistently and the bugs that used to eat your afternoon start taking minutes.
