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 andconsole.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 moreconsole.logcalls 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
catchblock; at minimum logerror.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.logcalls before shipping to production; use a logging library with levels for anything that should persist. - In Node.js, run
node --inspect-brk script.jsand openchrome://inspectto 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 bynumbers.length - 1instead ofnumbers.length). Useconsole.logto 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
processOrderexample from this lesson and add aconsole.table()call that prints theitemsarray before the total is calculated. Predict what the table's columns will be before you run it. - Write a function that throws a custom
Errorwith a descriptive message when passed a negative number, wrap a call to it in atry/catch, and log botherror.messageanderror.stackin the catch block. Compare how much more information.stackgives you versus.messagealone.
Summary
- Debugging combines print debugging (
console.logand friends) with interactive debugging (breakpoints and thedebuggerstatement). console.warn,console.error,console.table,console.group, andconsole.asserteach 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, andstack; 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-brkpluschrome://inspectto get full interactive debugging for Node.js scripts, not just browser code.
