How to debug code methodically: a junior developer's guide

The single biggest gap between a junior and a senior engineer isn't knowledge of frameworks or algorithms, it's how they debug. Seniors are methodical. Juniors flail. Here is the exact debugging process used at most professional engineering teams, written so you can adopt it on day one of your first internship.

The wrong way to debug (which everyone starts with)

The default debugging instinct goes like this:

  1. Read the error vaguely.
  2. Re-run the code hoping it works this time.
  3. Make random changes, change == to ===, add a try/catch, restart the server.
  4. Stack Overflow the exact error message.
  5. Copy-paste the first highly-upvoted answer.
  6. Pray.

This works about 30% of the time, and the other 70% you're stuck for hours wondering why your computer hates you. There's a better way.

The methodical approach: seven steps

Every senior engineer has internalized roughly the same debugging loop, even if they've never written it down. Here it is, explicit:

Step 1: Reproduce the bug reliably

You cannot fix a bug you can't reliably reproduce. The first thing you do is find the exact inputs, sequence of clicks, or test case that makes the bug appear every single time.

If the bug is intermittent, you don't fix it yet, you make it deterministic first. Add seed values to random number generators. Pin the timestamp. Use a specific test input. The goal is: type these commands, get this bug, every time.

If you can't reproduce it after 30 minutes, write down everything you know in a ticket and ask a teammate. Some bugs only happen on production, and that's a different conversation.

Step 2: Read the stack trace from the bottom up

Stack traces look intimidating. They're not. The trick is to read them bottom to top and find the first line that's in code you wrote.

Example:

TypeError: Cannot read properties of undefined (reading 'name')
    at Object.<anonymous> (/node_modules/react-dom/cjs/react-dom.development.js:14803:31)
    at HTMLUnknownElement.callCallback (/node_modules/react-dom/cjs/react-dom.development.js:3945:14)
    at Object.invokeGuardedCallbackDev (/node_modules/react-dom/cjs/react-dom.development.js:3994:16)
    at UserCard (/src/components/UserCard.jsx:14:21)
    at App (/src/App.jsx:8:9)

Most beginners read the top line ("TypeError... in react-dom...") and get confused trying to debug React itself. Don't. Skip past anything in node_modules, framework code, or test runners. The line that matters is the highest one in your own code: UserCard.jsx:14. That's where you go.

Open that file, that line. The error is "Cannot read properties of undefined (reading 'name')", meaning something on that line is undefined when you tried to access .name. That's all the info you need to start.

Step 3: Form one specific, falsifiable hypothesis

Don't say "I think there's a bug somewhere in this function." Say:

"I think the user prop is undefined when this component first renders, before the API call resolves."

That's a hypothesis. It's specific. It's falsifiable, you can run an experiment and prove or disprove it in 30 seconds. Hypotheses that are vague ("maybe React is weird") can't be tested and don't lead anywhere.

If your hypothesis is "I have no idea what's happening," you're not ready to debug yet, go back to Step 2 and read the trace more carefully, or print intermediate values to gather more data.

Step 4: Test the hypothesis with the smallest possible change

The cheapest test is a print statement (console.log, print, or your language's equivalent) right before the failing line:

console.log('user is:', user, 'type:', typeof user);
return <div>{user.name}</div>;  // crashes here

Run it. The log either confirms your hypothesis ("user is: undefined") or disproves it. Either result is good, both narrow the problem.

If you want to be fancier, set a breakpoint with your debugger (Chrome DevTools, VS Code's debug panel, pdb, etc.) and inspect the variable interactively. But honestly, 80% of debugging is well-placed print statements.

Practice debugging on real broken code

Reading about debugging is one thing. Doing it under time pressure on unfamiliar code is another. InternQuest gives you 150+ pre-broken codebases, each with a real bug to reproduce, hypothesize about, and fix. Free.

Try a debugging mission →

Step 5: Bisect when the cause isn't obvious

Sometimes the failing line itself looks fine. The bug is somewhere upstream, bad data flowed in from somewhere else. When that happens, you need to bisect: cut the problem in half repeatedly until you find the source.

Two ways to bisect:

Time-based (with Git): if the bug appeared recently, run git bisect. You mark a known-good commit and a known-bad commit; Git checks out a midpoint and asks you "good or bad?", keep answering until it pinpoints the exact commit that broke things. This is incredibly powerful.

git bisect start
git bisect bad                  # current is broken
git bisect good v1.4.0          # this version worked
# git auto-checks out a midpoint; test, then:
git bisect good   # or bad
# repeat until git names the offending commit

Code-path-based: in the failing function, comment out the second half of the logic. Does the bug still happen? If yes, it's in the first half, comment out half of that. Keep halving until you've isolated the failing line.

Step 6: Fix the actual root cause, not the symptom

This is where most juniors go wrong. The bug is "user is undefined when component renders." The lazy fix is:

return <div>{user?.name || 'Loading...'}</div>;

That suppresses the error, but the actual bug is still there: the API call is starting too late, or the loading state isn't being handled, or the parent component is mounting children before its data arrives. The lazy fix hides the symptom and creates a worse bug down the line, every other place that uses user now silently shows "Loading..." even when the data is genuinely missing.

Before you fix anything, ask: why did the bug exist in the first place? Once you can explain the root cause in one sentence, then you can choose where to fix it. Sometimes the right fix is in the parent component, not the failing one.

Step 7: Add a regression test

The bug existed because nothing was testing for it. Once you've fixed it, add a test that would have caught it:

test('UserCard renders nothing while user is loading', () => {
  render(<UserCard user={undefined} />);
  expect(screen.queryByText(/Loading/)).toBeInTheDocument();
});

This locks in the fix. Three months from now when someone else refactors UserCard, the test will fail if they break this case again. You won't even need to remember why this matters.

If you don't add a test, the bug will quietly come back. It always does.

Common debugging anti-patterns

"It works on my machine"

If a bug only happens on someone else's machine or in production, the bug is real, your machine is the outlier. Don't dismiss reports. Ask the reporter for their browser, OS, version, and exact reproduction steps. Then run the same test in their environment (or a Docker container that matches it).

Changing things you don't understand

"I'll just try changing this and see what happens" is fine for two minutes of exploration, but if you don't understand why a change fixed something, you've created technical debt for your future self. Whenever you fix a bug, you should be able to explain in plain English why your fix works. If you can't, you got lucky, and lucky fixes break in subtle ways later.

Marathon debugging sessions

If you've been stuck on the same bug for more than two hours, you're stuck for a reason, usually you've made a wrong assumption that you can't see anymore. Get up, walk around, explain the bug to a coworker (rubber duck), or come back tomorrow. Stepping away genuinely works. The number of bugs that "fix themselves" while you're in the shower is not coincidence.

Skipping the reproduction step

Trying to fix a bug you can't reproduce is fishing in the dark. You'll change code, ship it, and have no way to verify the fix. Always reproduce first.

The tools that pay off

You don't need fancy tools to debug well, but a few are worth learning:

How to think about hard bugs

The trickiest bugs share a pattern: your assumptions about the system are wrong somewhere, but you don't know where. Junior debugging stalls on these because the natural instinct is to keep poking at the code that's failing. The senior move is to question the assumptions.

Concrete checklist when you're truly stuck:

Each of these has caught me on bugs I was sure I understood. Trust nothing, verify everything.

Train debugging like seniors do: on broken code

InternQuest gives you 50+ debugging missions across Backend, Frontend, Security, and DevOps. Each one is realistic intern-grade broken code with a Jira-style ticket and an automated PR reviewer. Free, no credit card.

Start your first debugging mission →