Debugging Is a Search Problem
The difference between a developer who fixes a bug in twenty minutes and one who spends two days on it is rarely knowledge of the codebase. It's the size of the space they search, and how quickly they cut it in half.
Guessing is a linear search: try a thing, check, try another. Reading the error and reasoning about causes is a binary search: form a hypothesis that eliminates half the possibilities, test it, repeat. The second approach feels slower and is dramatically faster.
What follows is the method I've converged on over eighteen years, and it works the same whether the bug is in a stored procedure or a service worker.
Reproduce It First — Really Reproduce It
You cannot fix what you cannot trigger, and you cannot verify a fix you can't trigger. Before touching code, get to a state where you can make the bug appear on demand, and write down the exact steps.
Bug: checkout total is wrong
Steps:
1. Add item A ($19.99) to cart
2. Apply coupon SAVE10
3. Change quantity to 3
4. Total shows $49.97, expected $53.97
Environment: Chrome 141, macOS, logged in as test@example.com
Frequency: every time
Notice what that note does. It tells you the bug is deterministic (good — binary search applies), it isolates three variables, and it gives you a pass/fail signal for later. Half the time, writing it down reveals the cause before you've looked at a single line of code.
If it only happens "sometimes," your first job is to find the hidden variable: a race condition, a cache, a user-specific flag, time of day, data volume. Intermittent bugs are just deterministic bugs with an input you haven't identified yet.
Read the Entire Error Message
This sounds insultingly obvious and is nevertheless the most-skipped step in debugging. Developers read the first line of a stack trace, form a theory, and start editing. The answer is usually in line four.
TypeError: Cannot read properties of undefined (reading 'map')
at renderOrders (OrderList.jsx:42:18)
at OrderList (OrderList.jsx:12:5) <-- you stopped here
at renderWithHooks (react-dom.production.js:157)
at mountIndeterminateComponent
The useful information is "orders is undefined at render time," which means the fetch either hasn't resolved or returned something unexpected. The fix is almost never to add orders?.map() — that hides the symptom and produces an empty list instead of an error. The real question is why the component rendered before data existed.
Read stack traces bottom-up for framework internals and top-down for your own code. Read the caused by chain in Java and Python stack traces all the way to the end — the root cause is at the bottom, and the interesting exception is almost never the top one.
Cut the Search Space in Half
Every good debugging technique is a way of dividing the problem. Concretely:
- Bisect the code. Comment out half the logic. Still broken? It's in the other half. This is
git bisectfor runtime, and it works on unfamiliar codebases. - Bisect the data. Failing on 10,000 records but not 100? Something is scale-dependent — pagination, a timeout, memory, an N+1 query.
- Bisect the pipeline. Log the value at every hop: input, after transform, after network, after parse, before render. The bug lives between the last correct value and the first wrong one.
// Crude, temporary, and extremely effective
console.log('1 raw response', response.status, response.headers.get('content-type'));
const text = await response.text();
console.log('2 body length', text.length, text.slice(0, 200));
const data = JSON.parse(text);
console.log('3 parsed keys', Object.keys(data));
console.log('4 first item', data.items?.[0]);
Four logs tell you whether you have a network problem, an encoding problem, a parsing problem, or a data-shape problem. That's three-quarters of the search space eliminated by one function.
Work From the Outside In
There's a strong temptation to start where the symptom appears and dig deeper. Resist it. Start at the boundary closest to the user and walk inward, checking one assumption per layer:
- Is the request even leaving? Network panel, or
curl -v. You'd be surprised how often the answer is no — a failed preflight, a service worker cache, an ad blocker. - Did the server get what I think it got? Server-side log of the raw request. Not the parsed object — the raw body.
- Did the server respond with what I think? Check the actual response, not the SDK's deserialized result.
- Did the client parse it correctly? Timezone, encoding, float precision, date formats.
- Did the UI render what was parsed? Now finally look at the component.
Each layer has a cheap way to inspect it, and each inspection either confirms an assumption or destroys it. The bug is behind the first destroyed assumption.
Use the Right Tool at the Right Layer
Print statements are universal and underrated, but they're not always the fastest path. A few tools repay the ten minutes it takes to learn them:
- Conditional breakpoints — break only when
order.id === 4471. Essential when the bug only appears for one record out of ten thousand. - Logpoints — a breakpoint that logs instead of pausing. All the visibility of
console.log, zero redeploy, and you can toggle them live. - XHR/fetch breakpoints — pause the moment a request matching a URL pattern is sent. Invaluable when you don't know which code fired the call.
- DOM mutation breakpoints — break when an element's attributes or subtree change. The answer to "what is adding this class?"
- Source maps — if you're debugging minified production code, you're working with one hand tied behind your back. Build with source maps, upload them, use them.
debugger;— a literal pause in code. Fastest option when you already know the file.
// Pause only for the record you care about, no code edits needed:
// DevTools -> Sources -> right-click line number ->
// "Add conditional breakpoint" -> order.id === 4471
Change One Thing at a Time
When a fix isn't obvious, the temptation is to change three things and see if it works. If it does work, you've learned nothing and you now have three changes to untangle before you can ship confidently. Worse, if two of them were unnecessary, they're now unexplained risk in the codebase.
The discipline: one change, one test, one observation recorded. If the change didn't help, revert it before trying the next — accumulated failed experiments turn into a second bug.
Keep a scratch note:
10:15 - tried: disable cache -> still broken, reverted
10:22 - tried: log raw response -> body is empty, 204 not 200
10:31 - tried: check nginx config -> proxy returns 204 for POST >1mb
ROOT CAUSE: request size limit, not application logic
That note also has a second use: when someone asks what you've tried, you have an answer, and when you get interrupted you can pick up where you left off.
Explain It Out Loud
The "rubber duck" thing works for a specific reason: explaining forces you to fill gaps you'd been skipping over. Mid-sentence you'll hear yourself say "and then it just... waits" and realize you never verified that.
You don't need a colleague. Write a message describing the problem to someone, in detail, with what you've ruled out. Half the time you'll find the answer before sending it — and if you do send it, the recipient has everything they need to help instead of guessing.
Lock It In
A fix without a regression test is a fix you'll make twice. The test doesn't have to be elaborate; it has to fail before your change and pass after:
it('applies a percentage coupon after quantity changes', () => {
const cart = new Cart();
cart.add({ sku: 'A', price: 1999 });
cart.applyCoupon('SAVE10');
cart.setQuantity('A', 3);
// This was 4997 because the coupon was computed before quantity
expect(cart.total).toBe(5397);
});
Write the test first if you can — reproducing the bug as a failing test is the most reliable form of "reproduce it" there is, and it doubles as your verification step.
Then ask the follow-up question that separates a fix from a patch: what else has this same shape? If the coupon was applied before quantity was finalized, does the same ordering bug exist for tax? For shipping? For loyalty points? One root cause usually has siblings.
When the Bug Is "It's Slow"
Performance problems are debugging problems with a measurement instead of a stack trace. The same method applies, but the first step is always to measure — intuition about what's slow is wrong often enough to be worthless.
- Find the slow layer first. Network, server, database, render, or layout? A timing breakdown tells you in one pass.
- Look for N+1. The single most common server-side performance bug: a query inside a loop. 200 items means 201 queries.
- Check for layout thrashing. Reading
offsetHeightin a loop forces synchronous layout every iteration. Batch reads, then writes. - Count the bytes. An unoptimized 4MB image is a performance bug that no amount of code optimization will fix.
// Bad: reads and writes interleave, forcing layout each pass
items.forEach(el => {
el.style.height = el.offsetHeight + 10 + 'px'; // read, then write, repeat
});
// Good: all reads, then all writes
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] + 10 + 'px'; });
Set a budget and measure against it. "Feels slow" invents work; "time to interactive is 4.2s and the budget is 2s" tells you whether you need to fix anything at all.
The Whole Method
Six steps, in order:
- Reproduce it deterministically and write down the steps.
- Read the whole error, including the parts you skimmed.
- Cut the search space in half — code, data, or pipeline.
- Work outside in, verifying one assumption per layer.
- Change one thing, test, revert if it didn't help.
- Lock it in with a test, then look for siblings of the root cause.
None of this requires seniority or deep familiarity with the code. It requires resisting the urge to start typing. The developers who seem to have an uncanny instinct for bugs are almost always just running this loop faster and more faithfully than everyone else.