Debugging JavaScript in browser developer tools: a method, not a guess
A repeatable way to find the cause of a bug: reading error messages properly, using breakpoints instead of console.log, inspecting the network and the DOM, and the questions to ask before changing any code.
Most time spent "debugging" is spent changing code at random and reloading. A method is faster. This tutorial describes one, built around the developer tools that ship in every browser, and shows the specific panels and keystrokes for each step. Examples use Chrome's names; Firefox and Safari have the same tools under slightly different labels.
Step 0: reproduce it
Before touching anything, make the bug happen on demand. Write down the exact steps, the input, and what you expected versus what you saw. If it only happens sometimes, that is a clue in itself (timing, caching, state from a previous action). A bug you cannot reproduce cannot be confirmed fixed.
Step 1: read the error
Open the console (F12, or Ctrl+Shift+J / Cmd+Option+J). A JavaScript error has three parts, and all three matter:
Uncaught TypeError: Cannot read properties of undefined (reading 'name')
at render (todo.js:27:18)
at HTMLFormElement.<anonymous> (todo.js:41:3)- The type:
TypeErrormeans a value was not the type the code assumed;ReferenceErrormeans a name does not exist (typo, or not in scope);SyntaxErrormeans the file could not even be parsed, so nothing in it ran. - The message: "Cannot read properties of undefined (reading 'name')" says exactly what happened: something was
undefinedand the code didsomething.name. The question is which something. - The stack: file, line and column, innermost call first. Click
todo.js:27to jump to the line. The second frame saysrenderwas called from a form event handler at line 41.
The common translations: "is not a function" means you called something that is not a function, often because a lookup returned null or you misspelt a method. "is not defined" is a missing variable or a script that did not load. "Unexpected token <" when parsing JSON means the server sent HTML, usually an error page or a login redirect.
Step 2: find where reality diverges from your model
Somewhere between the input and the wrong output, a value is not what you think it is. The job is to find the first place that is true. There are two tools for looking at values.
console.log, done properly
console.log({ task, index, tasks }); // names and values together
console.table(tasks); // arrays of objects as a table
console.trace('who called this?'); // prints a stack trace without throwing
console.assert(id > 0, 'bad id', id); // only prints when the condition is falseWrapping variables in braces prints their names, which saves the "which log was 42?" problem. One caution: the console shows objects live. If you log an object and then mutate it, expanding the entry later shows the mutated version. Log JSON.stringify(obj) or structuredClone(obj) to freeze a snapshot.
Breakpoints
A breakpoint pauses the program at a line so you can see every variable in scope at that moment, without editing the code. In the Sources panel, open the file and click a line number. Reload or trigger the action; the page freezes at that line. Then:
- Hover any variable in the source to see its value, or read the Scope section on the right.
- Step over (F10) runs the current line and stops at the next. Step into (F11) follows a function call inside. Resume (F8) continues until the next breakpoint.
- Type expressions in the console while paused; they evaluate in the paused function's scope.
- Right-click a breakpoint for a conditional breakpoint: pause only when, say,
task.id === 7. This is how you catch the one bad iteration in a loop of a thousand.
Two breakpoints you set without finding a line: Pause on exceptions (the pause icon in Sources) stops at the exact statement that throws, with all state intact, which is usually the fastest route to a TypeError. And an event listener breakpoint (Sources, Event Listener Breakpoints, tick "click") pauses in whatever handler runs for a click, when you do not know which code is responding.
The debugger; statement in code is a breakpoint you can commit temporarily; remove it before shipping.
Step 3: check the data coming in
If the code looks right, the input might not be. The Network panel lists every request. Click one to see the status code, the request headers you sent, the response headers, and the response body under Preview or Response. Things to check: is the status 200? Is the Content-Type what the code expects? Is the body the shape the code expects (an array, not an object wrapping an array)? Was the request sent at all, and with the right query string? Filter by "Fetch/XHR" to hide images and scripts. "Disable cache" at the top avoids debugging a stale file.
Step 4: check the DOM
For "the click does nothing" and "the element is not there" bugs, the Elements panel shows the live DOM, which can differ from your HTML file: scripts add and remove things. Press Ctrl+Shift+C and click on the page to select the element under the cursor. With it selected, $0 in the console refers to it, so $0.dataset or getEventListeners($0) (Chrome) tells you what the code sees. The Styles pane shows which CSS rules apply and which were overridden; a crossed-out rule is one that lost the cascade, and a yellow warning triangle is an invalid value the browser discarded.
Step 5: form a hypothesis and test only that
By now you should be able to say "the id is a string here but a number there, so find never matches". Change one thing that would fix exactly that, re-run the reproduction from step 0, and confirm. If it is not fixed, undo the change before trying the next idea. Piling up speculative edits is how a one-line bug becomes an afternoon.
Classic causes, as a checklist
- The script ran before the element existed (script in
<head>withoutdefer). - A
querySelectorreturnednull: typo in the selector, or the element is inside an<iframe>or a shadow root. - String versus number:
dataset,input.valueand URL parameters are always strings. - A promise without
await, so the "result" is a promise. - The browser is running an old cached file. Hard reload (Ctrl+Shift+R) or disable cache in the Network panel.
- Two listeners for one event, so the action happens twice (one from a script included twice).
thisis not what you expect inside an arrow function or a detached method.- The CSS is correct but a more specific selector elsewhere wins; check for the crossed-out rule.
Pitfalls while debugging
- Leaving logs in. Search for
console.logbefore committing. Browsers can filter them, but readers of your code cannot. - Debugging minified code. Production bundles are unreadable; use source maps (the build tool generates them) or reproduce in development.
- Trusting the error line blindly. The line that throws is where the bad value was used; it was created earlier. Walk the stack upward.
- Changing two things at once. If it starts working you do not know why, and it will stop again.
Exercise
Take the to-do app from the DOM tutorial and introduce a bug on purpose: remove the Number() around li.dataset.id in the change handler. Reload, tick a box, and find the fault using only breakpoints: no console.log. Set a conditional breakpoint inside render that pauses only for the task you ticked, and inspect task.done. Then try Pause on exceptions with a second bug: rename tasks to task on one line.
Summary
- Reproduce first. Read the error type, message and stack, and click through to the line.
- Use breakpoints (including conditional ones and pause-on-exceptions) to see real values at the moment they go wrong.
- Check the Network panel for what actually arrived, and the Elements panel for what is actually on the page.
- One hypothesis, one change, re-test, undo if wrong.