No one writes bug-free code, and that is not the goal. The skill that separates experienced programmers from beginners isn't writing perfect programs — it's finding the inevitable mistakes quickly. A bug you can locate in two minutes costs nothing; the same bug hunted for two hours ruins your afternoon.
This lesson is about strategy: a repeatable process for turning 'it doesn't work' into 'I know exactly which line is wrong and why'. The good news is that debugging is a craft, not luck. Reproduce, observe, hypothesise, test, and narrow — those five moves, applied patiently, dissolve almost every defect. We'll build them up one at a time.
flowchart LR
O["observe the bug"] --> H["form a hypothesis"]
H --> T["test it<br/>log or assert"]
T --> Q{"fixed?"}
Q -- No --> H
Q -- Yes --> D["done"]
style O fill:#3776ab,color:#fff
style D fill:#1e7f3d,color:#fff
Step one: reproduce, then read
Before you can fix a bug you must be able to reproduce it — to trigger it on demand with a specific sequence of inputs. A bug that appears 'sometimes' is one you can never verify a fix for, so pin down the exact steps first. Once it reproduces reliably, slow down and read the error message: it almost always names the file, the line number, and what went wrong. Beginners skim past error text; experienced developers read every word, because the message is the program telling you exactly what failed and where.
function process(items) {
console.log("entered process with", items);
let total = 0;
for (let i = 0; i < items.length; i++) {
console.log(" i=" + i + " item=" + items[i]);
total += items[i];
}
console.log("total before return:", total);
return total;
}
console.log("result:", process([10, 20, 30]));
Log to see what your code actually does
Your mental model of what the code does and what it really does are two different things, and the bug lives in the gap. Logging is how you close that gap: drop console.log statements at the boundaries — function entry, loop iterations, just before a return — and watch the values flow past. The wrong value, the extra loop trip, the undefined you didn't expect: once you can see it, you can fix it.
Aim for a few well-placed logs over a wall of them. Log the inputs, the key intermediate result, and the output; if those three look right, the bug is somewhere you haven't logged yet. Each log narrows the search.
flowchart TD E["Error: x is undefined<br/>at stepB (line 4)<br/>at stepA (line 2)<br/>at main (line 9)"] --> N["newest call on top"] N --> R["the error happened in stepB, line 4"] style E fill:#b91c1c,color:#fff style R fill:#1e7f3d,color:#fff
Read the stack, newest first
When an error crashes your program, the stack trace printed beneath it is a map of how you got there. It lists the call stack with the most recent call on top: the first line is where the error actually happened, and each line below is the function that called it, walking back to where the run began. Read it top-down to find the exact origin, then look at the lines beneath to understand the path that led there. A stack trace turns 'something blew up' into 'this specific line, reached via these specific calls'.
State your assumptions with assertions
Much of debugging is discovering that an assumption you held quietly ('this number is always positive', 'this array is never empty') was wrong. An assertion turns that hidden assumption into an explicit check that fails loudly the moment it's violated, instead of letting the bad value flow on and cause a confusing symptom elsewhere.
You can build a tiny assert helper yourself: if the condition is false, throw an Error naming the assumption. Sprinkle assertions at the boundaries where data enters a function, and the instant a caller hands you something impossible, the program stops right there — at the cause — rather than five functions downstream at the symptom.
function assert(condition, message) {
if (!condition) {
throw new Error("assertion failed: " + message);
}
}
function divide(a, b) {
assert(b !== 0, "divisor must not be zero");
return a / b;
}
console.log(divide(10, 2));
try {
divide(10, 0);
} catch (e) {
console.log(e.message);
}
flowchart TD
S["suspicious range<br/>20 lines"] --> M["log or assert in the middle"]
M --> Q{"bug before or after?"}
Q -- before --> H1["keep the first half"]
Q -- after --> H2["keep the second half"]
H1 --> M
H2 --> M
style M fill:#b45309,color:#fff
Divide and conquer
When the bug could be anywhere in a long stretch of code, don't read all of it hoping to spot the mistake. Bisect instead: put a log or an assertion in the middle of the suspicious range. If the value is already wrong there, the bug is in the first half; if it's still right, the bug is in the second half. Keep the half that contains the bug and repeat. Each test cuts the search in half, so twenty suspicious lines shrink to one or two in four checks.
This is the same idea behind a binary search, and it works because you've turned a guessing game into a series of decisive yes-or-no questions. Name a hypothesis ('the total is wrong by the time we reach line 12'), test it once, and let the answer halve your search.
const price = 10;
const qty = 3;
const subtotal = price + qty; // suspicious: looks like it should multiply
const total = subtotal * 2;
console.log("subtotal:", subtotal);
console.log("total:", total);
You see a bug report: 'the total is sometimes wrong.' Before changing any code, what should you do FIRST?
Before you can fix a bug you must be able to REPRODUCE it. A flaky bug you can't trigger on demand is one you can't verify a fix for. Pin down the exact inputs first; the hypothesis, the test, and the fix all depend on it.
What does this print? The comment says it should sum 1 through 5 to 15. Predict the actual output — the gap between 15 and what you see is the bug.
// meant to sum 1 + 2 + 3 + 4 + 5 = 15
function sumTo(n) {
let total = 0;
for (let i = 0; i < n; i++) {
total += i;
}
return total;
}
console.log(sumTo(5));
The loop starts i at 0 and stops before n.
So it adds 0 + 1 + 2 + 3 + 4, not 1 + 2 + 3 + 4 + 5.
Write assertEqual(actual, expected) that throws new Error("got X expected Y") (with X and Y filled in) when the two values are not === equal, and returns true when they are.
function assertEqual(actual, expected) {
// throw if actual !== expected; otherwise return true
}
Compare with !==. If they differ, throw new Error("got " + actual + " expected " + expected).
Otherwise return true.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error("got " + actual + " expected " + expected);
}
return true;
}
This average returns NaN for perfectly good arrays. The loop bound is wrong — it walks one step past the end and reads undefined. Fix the one comparison so it sums exactly the elements that exist.
function average(nums) {
let sum = 0;
for (let i = 0; i <= nums.length; i++) {
sum += nums[i];
}
return sum / nums.length;
}
nums.length is a count, but valid indices run 0 .. length-1.
Change <= to < so the loop stops at the last real element.
function average(nums) {
let sum = 0;
for (let i = 0; i < nums.length; i++) {
sum += nums[i];
}
return sum / nums.length;
}
A form submits and the server saves an empty name. A developer 'fixes' it by making the server default empty names to 'Anonymous'. What is wrong with this fix?
Defaulting the name papers over the visible symptom but leaves the root cause untouched — the name is still empty for a reason, and that reason will resurface elsewhere. Debugging means tracing back to WHY the name is empty and fixing that, not masking the output.
Recap
- The first move in any bug is to reproduce it reliably and then read the error message — it usually names the file, line, and what went wrong.
- Log intermediate values to trace what your code actually does versus what you assumed.
- Assertions turn hidden assumptions into loud failures: state the invariant, fail immediately when it breaks.
- Divide and conquer: test in the middle of the suspect range and keep the half that contains the bug — each test halves the search.
- Debugging is a loop: observe, hypothesise, test, repeat. Never edit blindly, and fix the root cause, not the symptom.
Next you'll turn these one-off checks into a permanent safety net with unit tests.