JavaScript Debugging

Debugging is the process of finding out why your code isn’t doing what you expect, and fixing it. Every JavaScript developer spends a huge share of their time debugging, not writing new code, so getting good at it is one of the highest-leverage skills you can build. This lesson covers the full toolkit: the console object, the debugger statement, breakpoints in browser DevTools and Node’s inspector, and how to read error stack traces so you can find the real cause of a bug instead of guessing.

Overview: How Debugging Works

JavaScript runs inside an engine (V8 in Chrome and Node.js, SpiderMonkey in Firefox, JavaScriptCore in Safari) that executes your code one statement at a time on a single call stack, coordinated by an event loop for anything asynchronous. Debugging means observing that execution as it happens: what value a variable holds at a given line, which function called which, and what the engine was doing the moment something went wrong.

There are two broad debugging strategies, and skilled developers use both together. The first is print debugging: sprinkling console.log() (and its relatives) through your code to print variable values and execution markers as the program runs. It’s fast, requires no setup, and works everywhere — in the browser console, in a terminal running Node, even in embedded environments. The second is interactive debugging: pausing execution at a specific line with a breakpoint (set in DevTools, in an editor like VS Code, or via the debugger statement) and then stepping through the code line by line while inspecting live variables, the call stack, and closures in the running scope.

When you pause at a breakpoint, the engine freezes the call stack exactly as it is. The debugger UI shows you every frame on that stack — which function is executing, what called it, and what called that — along with the local variables, the closure scope, and the global scope available at that point. You can then step over a line (run it and pause on the next one), step into a function call (follow execution inside it), or step out of the current function back to its caller. This is far more powerful than console.log because you can inspect anything in scope, not just what you thought in advance to print.

Errors add a second debugging surface: the stack trace. When a JavaScript error is thrown, it carries a .stack string recording every function call that was active at the moment it was created, from innermost to outermost. Reading that trace top to bottom tells you exactly which call path led to the failure, which is often faster than stepping through code manually.

Syntax

The most common debugging tools have simple syntax:

Tool Syntax What it does
console.log() console.log(value1, value2, ...) Prints values for general inspection
console.warn() console.warn(message) Prints a warning, often styled/colored differently
console.error() console.error(message) Prints an error, usually to stderr, with a stack trace in browsers
console.group() console.group(label)console.groupEnd() Indents subsequent logs under a collapsible label
console.table() console.table(arrayOrObject) Renders an array/object as a readable table
console.assert() console.assert(condition, message) Logs the message only if the condition is falsy
console.trace() console.trace(label) Prints the current call stack
debugger debugger; Pauses execution at that line if a debugger/DevTools is attached

The debugger statement is especially useful because it’s a permanent breakpoint you write directly in your source code. With no debugger attached, it does absolutely nothing — it’s safe to leave in code temporarily. With Chrome DevTools open (or Node run as node --inspect-brk script.js), execution pauses right there:

function calculateDiscount(price, percent) {
  debugger; // execution pauses here only when a debugger/DevTools is attached
  const discount = price * (percent / 100);
  return price - discount;
}

console.log(calculateDiscount(200, 15));

Output:

170

Without an attached debugger this just runs normally and prints 170. Attach DevTools or Node’s inspector, though, and execution freezes on the debugger; line, letting you inspect price and percent before stepping onward.

Examples

Example 1: Print debugging a calculation

The simplest and most common technique is logging values at key points to trace how data flows through a function.

function calculateTotal(items) {
  console.log("Starting calculateTotal with items:", items);
  let total = 0;
  for (const item of items) {
    console.log(`Adding price: ${item.price}`);
    total += item.price;
  }
  console.log("Final total:", total);
  return total;
}

const cart = [
  { name: "Book", price: 12.99 },
  { name: "Pen", price: 1.5 },
];

calculateTotal(cart);

Output:

Starting calculateTotal with items: [ { name: 'Book', price: 12.99 }, { name: 'Pen', price: 1.5 } ]
Adding price: 12.99
Adding price: 1.5
Final total: 14.49

Each console.log call marks a checkpoint. If the final total were wrong, you’d be able to see exactly which iteration added an unexpected price, without needing a debugger at all.

Example 2: Organizing logs with console.group and console.warn

When a function processes multiple items, plain console.log output becomes a wall of text. Grouping related logs makes it far easier to scan.

function processOrder(order) {
  console.group(`Processing order #${order.id}`);

  if (!order.items.length) {
    console.warn("Order has no items!");
  }

  let total = 0;
  for (const item of order.items) {
    total += item.price * item.quantity;
  }

  console.log(`Order total: $${total.toFixed(2)}`);
  console.groupEnd();
  return total;
}

processOrder({
  id: 101,
  items: [
    { price: 9.99, quantity: 2 },
    { price: 4.5, quantity: 1 },
  ],
});

processOrder({ id: 102, items: [] });

Output:

Processing order #101
  Order total: $24.48
Processing order #102
  Order has no items!
  Order total: $0.00

console.group indents every log call made until the matching console.groupEnd(), which is invaluable when a function is called many times in a loop and you want each call’s output visually separated. The console.warn call also flags the empty-order case, which is exactly the kind of thing you’d want to notice while scanning logs.

Example 3: Debugging with try/catch and error properties

Errors are objects with useful properties — name, message, and stack — that tell you what failed and, via the stack, where.

function withdraw(account, amount) {
  if (amount > account.balance) {
    throw new Error(`Insufficient funds: tried to withdraw ${amount}, balance is ${account.balance}`);
  }
  account.balance -= amount;
  return account.balance;
}

function processWithdrawal(account, amount) {
  try {
    const newBalance = withdraw(account, amount);
    console.log(`Withdrawal successful. New balance: ${newBalance}`);
  } catch (error) {
    console.error(`${error.name}: ${error.message}`);
  }
}

const account = { balance: 50 };
processWithdrawal(account, 100);
processWithdrawal(account, 20);

Output:

Error: Insufficient funds: tried to withdraw 100, balance is 50
Withdrawal successful. New balance: 30

The first call fails validation and throws, so the catch block logs a clear, descriptive message instead of letting the program crash. The second call succeeds against the unchanged balance of 50. Notice that logging error.message (rather than just error) gives you a clean, readable string — in a browser or with console.trace() you’d also get the full call stack showing exactly which function called withdraw.

Under the Hood: What Happens When You Debug

When you set a breakpoint (via DevTools, an editor, or a debugger statement) and the engine reaches that line, it doesn’t just stop — it suspends the entire JavaScript thread. Because JavaScript is single-threaded, this pause is total: no other script on the page runs, no timers fire, no event handlers execute, until you resume. This is why a breakpoint inside an event handler freezes the whole application, not just that function.

While paused, the debugger UI reads directly from the engine’s internal representation of the call stack. Each stack frame corresponds to one active function invocation and carries its own scope chain: local variables, any variables captured by closure from enclosing functions, and finally the global scope. This is why you can inspect closure variables that aren’t visible anywhere in your source at that line — the debugger is showing you the engine’s actual runtime state, not a re-parsed guess.

Stepping commands map onto this model directly. Step over tells the engine to run the current line (including any function calls it makes) to completion and pause again at the next line in the same frame. Step into tells it to pause at the first line of whatever function is called on the current line, pushing a new frame onto the stack. Step out lets the current frame finish and pauses back in its caller’s frame. Watching the call stack panel as you do this is the fastest way to build an accurate mental model of your program’s real control flow, especially in code with callbacks or recursion where the flow isn’t obvious from reading the source alone.

For asynchronous code, modern debuggers also track async stack traces: even though a Promise callback runs on a later turn of the event loop with an empty native call stack, the engine stitches together the logical chain (“this then handler was scheduled by this fetch call, which was called from this function”) so the stack trace still tells a coherent story instead of stopping cold at the microtask boundary.

Common Mistakes

Mistake 1: Swallowing errors silently

An empty catch block hides the very information you need to debug a problem. It makes failures invisible instead of loud.

function fetchUserAge(user) {
  try {
    return user.profile.age;
  } catch (e) {}
}

console.log(fetchUserAge({}));

Output:

undefined

The function silently returns undefined with no clue that user.profile didn’t exist. Anyone calling this function later has no idea whether undefined means “no age set” or “something broke.” Always log or otherwise surface what went wrong:

function fetchUserAge(user) {
  try {
    return user.profile.age;
  } catch (error) {
    console.error("Could not read user age:", error.message);
    return null;
  }
}

console.log(fetchUserAge({}));

Output:

Could not read user age: Cannot read properties of undefined (reading 'age')
null

Now the cause is explicit in the logs, and the function returns a clearly-intentional null rather than an ambiguous undefined.

Mistake 2: Trusting code without verifying its output

An off-by-one error is one of the most common bugs, and it’s easy to miss just by reading the code because the logic looks right.

function sumRange(start, end) {
  let sum = 0;
  for (let i = start; i < end; i++) {
    sum += i;
  }
  return sum;
}

console.log(sumRange(1, 5));

Output:

10

The intent was to sum 1 through 5 inclusive (which is 15), but i < end stops before i reaches 5, so the function only sums 1+2+3+4. This is exactly the kind of bug print debugging catches instantly — log the result, compare it to what you expected by hand, and the discrepancy points straight at the loop condition. The fix is a one-character change:

function sumRange(start, end) {
  let sum = 0;
  for (let i = start; i <= end; i++) {
    sum += i;
  }
  return sum;
}

console.log(sumRange(1, 5));

Output:

15

The lesson generalizes: never assume a function is correct just because it compiles and runs without throwing. Always check its output against a hand-computed expectation for at least one simple case.

Best Practices

  • Use console.table() for arrays of objects — it's far easier to scan than nested object dumps.
  • Use console.group()/console.groupEnd() to keep logs from loops or recursive calls visually separated.
  • Prefer console.error() for actual failures and console.warn() for suspicious-but-recoverable states, so severity is visible at a glance and filterable in DevTools.
  • Log labeled values (console.log("total:", total)) rather than bare values, so you can identify which log produced which output once you have several.
  • Reach for a real breakpoint (DevTools or debugger;) instead of more console.log calls once you need to inspect many variables at once or step through control flow — it's faster and doesn't require editing the source repeatedly.
  • Never leave an empty catch block; at minimum log error.message, even in code you think can't fail.
  • Read stack traces from the top down: the first line is where the error was created, and each subsequent line is a caller further up the chain.
  • Remove or gate debug console.log calls before shipping to production; use a logging library with levels for anything that should persist.
  • In Node.js, run node --inspect-brk script.js and open chrome://inspect to get full DevTools-style breakpoint debugging for server-side code, not just the browser.

Practice Exercises

  • Write a function average(numbers) that has a deliberate bug (for example, dividing by numbers.length - 1 instead of numbers.length). Use console.log to print the sum and the count inside the function, run it against [2, 4, 6], and use the printed values to find and fix the bug.
  • Take the processOrder example from this lesson and add a console.table() call that prints the items array before the total is calculated. Predict what the table's columns will be before you run it.
  • Write a function that throws a custom Error with a descriptive message when passed a negative number, wrap a call to it in a try/catch, and log both error.message and error.stack in the catch block. Compare how much more information .stack gives you versus .message alone.

Summary

  • Debugging combines print debugging (console.log and friends) with interactive debugging (breakpoints and the debugger statement).
  • console.warn, console.error, console.table, console.group, and console.assert each format output for a different debugging need.
  • A breakpoint freezes the entire single-threaded call stack, letting you inspect every scope active at that line; stepping over/into/out of code maps directly onto pushing and popping stack frames.
  • Thrown errors carry name, message, and stack; reading the stack top-down shows the exact call chain that led to the failure.
  • Never swallow errors silently, and always verify a function's actual output against your expectation rather than trusting that correct-looking code is bug-free.
  • Use node --inspect-brk plus chrome://inspect to get full interactive debugging for Node.js scripts, not just browser code.