Back to articles
engineering July 28, 2026 6 min read

The Four Stages of Debugging and How to Master Them

Debugging isn’t a mystery—it follows a predictable mental loop. Learn the four classic stages and turn every crash into a confidence boost.

Debugging feels like a roller‑coaster you never signed up for. One moment you’re staring at a cryptic stack trace, the next you’re celebrating a tiny change that magically made everything work again. If you’ve ever muttered the four classic stages of debugging—How could this ever happen?How did this EVER work?Ah, I see.Wait, why did that fix it?—you’re not alone. Those phrases capture a mental pattern that repeats across languages, frameworks, and even hardware. In this post we’ll unpack each stage, give you concrete tactics to accelerate the transition, and provide a live JavaScript example that walks through the whole cycle.

1. The Shock: "How could this ever happen?"

The first stage is pure disbelief. Something that should work is blowing up, and your brain is busy rejecting the reality of the bug. This is where you often see:

What to do in this stage

The key is to stop the panic loop and gather raw data. Treat the error like a forensic clue rather than a personal affront.

Quick checklist (blank line before list)

By turning the shock into a data‑collection mission, you set the stage for the next, more analytical phase.

2. The Retrospective: "How did this EVER work?"

Once the immediate panic subsides, you start asking yourself how the code ever functioned correctly. This is the reverse‑engineering phase: you reconstruct the assumptions that previously held true.

Techniques for the retrospective

Example: A mysterious undefined error

function fetchUser(id) {
  // Old code assumed `api.get` always returned an object
  const response = api.get(`/users/${id}`)
  return response.data.name
}

When the function started throwing Cannot read property 'name' of undefined, the immediate reaction is shock. In the retrospective stage you ask:

By digging into the commit history you might discover a recent upgrade of axios that now throws on non‑2xx responses instead of returning a response object. That explains why the code used to work.

3. The Insight: "Ah, I see."

At this point the mystery clicks. You have identified the root cause, often a single line or a missing guard. The moment of clarity is both satisfying and dangerous—if you jump to a fix without confirming the hypothesis, you risk a regression.

Confirming your hypothesis

Applying the insight to the example

We now know that api.get can return null on a 404. A safe fix adds a guard:

function fetchUser(id) {
  const response = api.get(`/users/${id}`)
  if (!response || !response.data) {
    throw new Error(`User ${id} not found`)
  }
  return response.data.name
}

Notice the added if (!response || !response.data) check. Before committing, we write a test that simulates a 404 response and ensures the function throws as expected.

test('fetchUser throws on missing user', () => {
  // Mock api.get to return null
  api.get = jest.fn().mockReturnValue(null)
  expect(() => fetchUser(999)).toThrow('User 999 not found')
})

Running the test confirms that our hypothesis about the missing guard is correct.

4. The After‑thought: "Wait, why did that fix it?"

Even after a fix works, engineers often wonder why the change resolved the issue. This stage is crucial for learning and preventing future regressions.

Turning a fix into knowledge

Example of a good post‑mortem note

## Bug: fetchUser crashes on 404
- **Root cause**: `api.get` now returns `null` for non‑2xx responses after upgrading to axios v1.
- **Fix**: Added guard for missing `response` or `response.data` and threw a descriptive error.
- **Tests added**: `fetchUser throws on missing user` ensures future changes keep the guard.
- **Action items**: Update internal API wrapper to normalize axios responses to always return an object with `data`.

By writing this short note, you close the loop and make the debugging cycle a learning loop.

Putting the Four Stages into Practice

Most developers go through these stages intuitively, but you can formalize the process to reduce time spent stuck in the "shock" or "after‑thought" phases.

Conclusion

Debugging is less a mysterious art and more a repeatable mental algorithm. By recognizing the four stages—shock, retrospective, insight, and after‑thought—you can steer your mind through the chaos with purpose. The next time you hear yourself mutter "How could this ever happen?", remember that you’re simply at the start of a well‑trodden path. Follow the steps, document the journey, and turn every crash into a stepping stone toward more robust code.

Need something like this built?

I work on full-stack web apps — backend systems, APIs, and the front-ends that sit on top. If this post was useful and you've got a project that needs it, I'd like to hear about it.

Want future posts like this?

No mailing list yet — for now, email me and I'll let you know when something new goes up.

Email me