All chapters Part VI · Quality
Chapter 29

Debugging: A Systematic Method

Loud bugs are cheap, a stack trace is a gift. The expensive ones produce no error at all: they return success, render something plausible, exit zero, and quietly do the wrong thing. Learn to recognise their shapes and you find them in minutes instead of days.


The concept

Debugging feels like intuition and is actually a method. The method matters most precisely when intuition fails, when you've tried three fixes and none of them worked.

The core discipline: form a hypothesis that could be wrong, then test it. Most bad debugging is a sequence of plausible changes with no hypothesis attached, which is indistinguishable from guessing.

⭐ The six shapes of expensive bugs

Almost every bug that costs more than an hour is one of these. Learning to recognise them is the highest-leverage skill in this chapter.

Shape 1, Silent success. Something failed and reported success. No exception, no non-zero exit, no log. The most common and most expensive shape.Countermeasure: every operation that can partially fail must assert its own post-condition and fail loudly.

Shape 2, The helpful fallback. A defensive default turned a failure into plausible wrong behaviour. Every one was written with good intentions. → Countermeasure: fallbacks are fine; silent fallbacks are not. Log, count, or surface every one. Never use a fallback around money, identity, or permission.

Shape 3, The layer below yours. A platform, framework, or runtime did something after your code, without telling you. → Countermeasure: ask "what runs after me?" and "what's common across all the failing cases?"

Shape 4, One variable, two meanings. A value that answers two different questions works until they diverge. → Countermeasure: model facts, not positions (Chapter 20). If a name could be a count or an index, split it.

Shape 5, The untested rare path. Code that runs once, on a path your development environment never hits, reinstall, second device, expired state, empty account. → Countermeasure: enumerate rare paths explicitly and test each on a clean install.

Shape 6, Systematic bias at scale. Fine in every individual case, catastrophic in aggregate. → Countermeasure: measure distributions, not samples.

The method

 1. REPRODUCE RELIABLY
    If you can't reproduce it, you can't verify a fix.
    Narrow to the smallest case that still fails.

 2. PROVE YOUR CHANGE IS RUNNING       ⭐ do this before anything else
    Visible marker → confirm → then debug. (Chapter 28)

 3. READ THE ERROR. Actually read it. All of it. Including the stack.

 4. WHAT CHANGED?
    git log · dependency updates · config · data · someone else's deploy

 5. BISECT
    git bisect, or comment out half. Halve the search space each time.

 6. FORM A HYPOTHESIS THAT COULD BE WRONG
    "I believe X because Y. If true, Z would also be true."
    Then test Z.

 7. ⭐ AFTER THREE FAILED FIXES: STOP.
    Your MODEL is wrong, not your implementation.
    Do not generate a fourth variation.

 8. ASK THE UNLOCKING QUESTIONS
    · What is COMMON across all failing cases?
    · What runs AFTER my code?
    · Is this evidence of failure, or absence of evidence?
    · What does this variable actually MEAN?
    · What reads this back, and when?

 9. FIX THE CAUSE, NOT THE SYMPTOM. Ask "why" three times.

10. RE-RUN EVERY CHECK. Fixes create new bug classes.

11. COMMENT THE FIX EMPHATICALLY if it's non-obvious.

12. LOOK FOR OTHER INSTANCES OF THE SAME SHAPE.

Absence of evidence is evidence

A subtle and repeatedly useful idea. Some observations tell you something precisely because of what's missing:

ObservationNaive readingWhat it actually means
Email in Sent, not in inbox, not in spam"Spam filter"Never accepted, rejected upstream
Exit code 0, nothing happened"It worked"Pattern matched nothing
No log line at all"It didn't run"Or it ran and the logger failed
No error, wrong output"Logic bug"Often a silent fallback

Learn to read absences. They're often more diagnostic than the presence of an error.

Bug reports that are actually useful

From users or teammates, insist on:

  1. What you did, exact steps
  2. What you expected
  3. What happened
  4. A screen recording
  5. Device, OS, app version, network

Screen recordings are the highest-value QA artifact available. They carry timing, gesture, and the exact failure frame, everything prose loses. "It's stuttering" is undiagnosable; "it's stuttering" plus a video is a bug report.

Production debugging

You can't attach a debugger. You need, in order of value:

  1. Structured logs with a request/session id threading through
  2. Error tracking with stack traces and context (Chapter 38)
  3. Session replay for frontend
  4. Feature flags to disable a suspect path without a deploy
  5. Distributed tracing once you have multiple services

Log at boundaries, every external call, every state transition, every failure branch. Include enough context to reconstruct what happened without the user.


📐 Best practice

Reproduce before fixing.

Prove your change is running.

Bisect rather than reasoning about large search spaces.

After three failed fixes, question the model.

Ask "what's common across the failures?"

Fix causes; ask why three times.

Comment non-obvious fixes emphatically, someone will "simplify" them back into the bug.

Re-run all checks after fixing.

Hunt for other instances of the same shape.

Instrument failures as events, not silent catches.

Keep a bug journal. Patterns emerge that individual fixes hide.


💀 Common mistakes

Thrashing. Repeatedly changing things without a hypothesis.

Fixing the symptom. The bug returns wearing a different hat.

Not reproducing first. You have no way to know if you fixed it.

Debugging code that isn't running.

Ignoring the actual error text.

Assuming your dependencies are correct, and assuming they're wrong. Test, don't assume.

Changing several things at once. Now you don't know which fixed it.

Not checking what changed recently. Most bugs are recent.

Silent catches. try {} catch {} with an empty body is how bugs become invisible.

Fixing forward only. New code is safe; the same pattern still exists in five older places.


The professional workflow

The method above, as a checklist you can run when you're stuck and not thinking clearly, which is exactly when you need it:

 REPRODUCE      smallest case that still fails
 PROVE          your change is actually running          ⭐ first, always
 READ           the whole error, including the stack
 DIFF           what changed? code · deps · config · data
 BISECT         halve the search space, don't reason across it
 HYPOTHESISE    "I believe X. If true, Z is also true." Test Z.
 ─────────────────────────────────────────────────────────────
 AFTER 3 FAILED FIXES → STOP. The model is wrong, not the code.
 ─────────────────────────────────────────────────────────────
 REFRAME        what's COMMON across failures?
                what runs AFTER my code?
                is this failure, or ABSENCE of evidence?
                what does this variable actually MEAN?
                what reads this back, and WHEN?
 FIX THE CAUSE  ask "why" three times
 RE-VERIFY      re-run every check; fixes create new bug classes
 COMMENT        emphatically, if the fix is non-obvious
 SWEEP          find other instances of the same shape

The horizontal rule is the important part. Most time lost to debugging is spent below it, generating a fourth, fifth and sixth variation of a fix that was never going to work, because all of them were correct given a model that was wrong.


Tools, websites & costs

NeedToolCost
Error trackingSentry, Rollbar, CrashlyticsFree tiers
Session replayPostHog, Highlight, LogRocketFree tiers
Structured loggingPino, Better Stack, AxiomFree tiers
Feature flagsPostHog, Flagsmith, UnleashFree tiers
TracingOpenTelemetry + any backend$0 + backend
Bisectinggit bisect$0
Mobile inspectionSafari Web Inspector, Chrome DevTools, Flipper$0
Network inspectionProxyman, Charles, DevTools$0-69
Bug reportsBuilt-in screen recorders$0, the best one
API replayHookdeck, Webhook.siteFree tiers

Alternatives & trade-offs

Print debugging vs a debugger. Debuggers are better for complex state; print/log is better for timing, async, and production. Both are legitimate; print debugging is not unprofessional.

Local reproduction vs production observation. Local is faster to iterate; some bugs only exist with production data, scale, or configuration. Invest in observability so you can debug where the bug actually is.

Fix now vs record and batch. Fixing every bug immediately destroys focus. Batching risks never fixing them. Critical and security: now. Everything else: a tracked list with dates (Chapter 30).

Defensive coding vs failing fast. Defensive code keeps things running and hides problems. Failing fast surfaces bugs immediately and can crash for users. Fail fast internally; degrade gracefully at the edges, and always log the degradation.


Checklist


📓 Case Study: nine expensive bugs, eight with no error message

Project: SOLIS. Going back through every bug that cost more than an hour produced an uncomfortable and useful statistic: eight of the nine produced no error message at all. None crashed. All of them "worked."

The catalogue, by shape:

#BugShapeCost
1Screens opened at the previous screen's scroll positionLayer below2 days
2Paywall granted every user free accessSilent success + fallbackweeks live
3Build reported success over a failed build, installed stale binarySilent successcorrupts all debugging
4Cleanup regex matched nothing; 263 MB accumulatedSilent successnear-disaster
539 images silently showed a placeholderHelpful fallback~1 day, twice
6Emails sent, never delivered, not in spamLayer belowan evening
7Completion marks on the wrong itemOne variable, two meaningssurvived 22 days
8Correct answer in position 2 in 294/300 questionsSystematic biaswould have shipped
9Sync uploaded but never downloadedUntested rare patha week of false confidence

Bug 1, the two-day one, and the reframe that solved it.

Five reasonable fixes failed in sequence: scroll-to-top after render, the same inside an animation frame, setting the container's scroll position, a temporary CSS lock, and all of them combined.

Then the question that cracked it:

"check if we are using something common for all the lesson, issue must be there"

The common thing was the document itself. The platform's WebView silently restored the document's native scroll offset when content was swapped, after the JavaScript ran, with no event to hook. You cannot win that by trying harder in your own layer; the fix was architectural (move scrolling into an inner container, so the platform behaviour doesn't apply).

Five reasonable fixes failing is the signal that your model is wrong. Each was correct given a wrong model.

Bug 6, absence of evidence, read correctly. Confirmation emails appeared in Sent and never arrived. Two rounds were spent on the obvious suspect (permissions), wrongly. Then:

"its there in sent, but its not there in spam or any other folder in reciver side? (is it autodeleting or something?)"

Not in spam is the tell. Spam means delivered-and-filed. Absent from spam means never accepted, the receiving server discarded it upstream, because the domain had no sending authentication published (Chapter 35). The instinct in that message was exactly right.

Bug 4, silent success in its purest form. A cleanup script's pattern used syntax that means something different on the platform it ran on. It matched nothing, no error, no output, exit code 0. Files accumulated in a tracked directory until they were 263 MB and one commit from permanent history.

The fix wasn't just correcting the pattern; it was counting what remained afterwards and failing loudly if it wasn't zero.

The pattern that emerged. "Silent success" appeared four separate times in 24 days, in four unrelated subsystems, a cleanup script, a build pipeline, an asset loader, and a payment stub. Nobody connected them until the bugs were catalogued together.

📐 That's the argument for keeping a bug journal. Individual fixes hide patterns. A list of them reveals that you have one recurring architectural weakness, not nine unrelated incidents, and fixing the class is far cheaper than fixing nine instances.

The unlocking questions, in the order they earned their keep:

  1. "What's common across the failing cases?", solved bug 1
  2. "Is this evidence of failure, or absence of evidence?", solved bugs 6 and 4
  3. "What does this variable actually mean?", solved bug 7
  4. "What reads this back, and when?", would have prevented bug 9
  5. "What runs after my code?", bugs 1, 3, 6

⚠️ Deviation: fixes were applied forward but rarely swept backward.

When the "silent fallback" pattern was understood in one place, the other instances weren't hunted down. Bug 5's shape, a graceful fallback hiding a failure, had already occurred once before with a different asset set and hadn't been generalised.

Fixing forward is natural. Sweeping backward for the same shape is the discipline, and it's where most of the leverage is.


Lessons

  1. ⭐ The expensive bugs are silent. Eight of nine produced no error. Optimise your defences for silence, not crashes.
  2. Six shapes cover almost everything. Silent success, helpful fallback, the layer below, one-variable-two-meanings, untested rare path, systematic bias.
  3. "Silent success" is the dominant shape. Four instances in 24 days across unrelated subsystems.
  4. After three failed fixes, your model is wrong. Stop generating variations.
  5. "What's common across the failures?" is the highest-yield question in debugging.
  6. Absence of evidence is evidence. Not-in-spam and exit-0 each mean something specific.
  7. One variable answering two questions is a bug with a delivery date.
  8. The rare path is the untested path. Reinstall, second device, expired, empty.
  9. Comment hard-won fixes emphatically, or they get simplified back into the bug.
  10. Keep a bug journal. Nine incidents were really one recurring weakness.
  11. Sweep backward. Fixing forward leaves the same shape in five older places.

Next: Chapter 30: Security and Code Review →

Useful? Share this chapter