23 min read

Chapter 5 — The Browser as a Debugger

DevTools, Used With Intent

Part 2: Instruments. Prerequisites: Chapters 1–4.


Opening

Every frontend engineer opens DevTools. Few use more than a tenth of it. The usual pattern is: open the Console, look for red, add console.log, refresh, repeat.

DevTools is not a log viewer. It is a set of instruments, and each one answers a different question. The Network panel answers "what did the browser actually send and receive?" The Sources panel answers "what is this code doing, line by line, right now?" The Application panel answers "what state is the browser holding that I can't see?" When you know which question you are asking, you know which panel to open — and you stop guessing.

This chapter walks through DevTools the way a senior engineer uses it during an investigation. It uses Chrome's names and shortcuts; Firefox and Safari have the same instruments under slightly different names.


Part 1: The Map — Which Panel Answers Which Question

Open DevTools with Cmd+Option+I (Mac) or Ctrl+Shift+I (Windows/Linux). Learn one more shortcut immediately: Cmd/Ctrl+Shift+P opens the Command Menu, which can run any DevTools feature by name — you never need to hunt through menus.

PanelThe question it answers
ConsoleWhat errors and warnings happened? What is the value of this expression in the page, right now?
NetworkWhat requests did the page make, what exactly was sent and received, and how long did each part take?
SourcesWhat is the code doing, step by step? Where can I pause it?
ApplicationWhat is stored — cookies, local storage, IndexedDB, caches, service workers?
ElementsWhat is the DOM and CSS right now, and why does this element look like this?
PerformanceWhat did the main thread spend its time on?
MemoryWhat objects are alive, and why? (Covered in depth in FE-07.)

The rule of thumb for an investigation: Console first, Network second, then whichever panel the evidence points to.


Part 2: The Network Panel — What Actually Went Over the Wire

Most frontend bugs are really "the data was not what I expected" bugs. The Network panel is the ground truth: not what your code meant to send, but what the browser actually sent and received.

Set it up before you reproduce

Three settings at the top of the panel change what you can see:

  • Preserve log — keep requests across page navigations and reloads. Without it, a login redirect or a form submission that navigates away wipes the evidence. Turn it on whenever a bug involves a redirect or navigation.
  • Disable cache — bypass the browser's HTTP cache while DevTools is open. Use it to rule out stale cached responses. (It does not bypass a service worker — see Part 5.)
  • Throttling — simulate a slow or offline network (Fast 4G, Slow 4G, Offline). Many bugs only appear when responses are slow: race conditions, double submissions, loading states that never resolve.

Filter instead of scrolling

The type buttons (Fetch/XHR, Doc, JS, CSS, Img, WS) narrow by kind. The filter box also accepts properties, which is much faster than scrolling:

FilterShows
status-code:500Only responses with that status
method:POSTOnly POST requests
domain:api.example.comOnly requests to that host
larger-than:500kOnly large responses
-domain:analytics.example.comEverything except that host (a leading - negates)
is:runningRequests still in progress

Read the columns

ColumnWhat to look for
StatusThe status code — or (failed), (canceled), (blocked:…), (pending). Chapter 4 decodes all of these
InitiatorWhich code started this request. Click it to jump to the exact line
SizeBytes transferred. (memory cache), (disk cache) or (ServiceWorker) mean no network request happened
TimeTotal duration
WaterfallWhen the request started and how its time was spent, relative to the others

Right-click the column header to add more. Two worth adding: Protocol (h2, h3, http/1.1) and Remote Address (the actual IP the browser connected to — useful when DNS might point somewhere unexpected).

Inside one request

Click a request to see its tabs:

  • Headers — the request URL, method, status, and every request and response header. This is where you check whether the Authorization header or cookie was actually sent, what content-type came back, and which hop answered (server, via, x-vercel-id, cf-ray — Chapter 4). If you see the warning "Provisional headers are shown", the request never completed normally — it was served from cache, blocked, or cancelled — so the headers shown are only what the browser intended to send.
  • Payload — the query string and the request body as sent.
  • Preview / Response — the response body, formatted or raw. If you expected JSON and see HTML here, you have found the cause of an Unexpected token '<' error.
  • Initiator — the call stack that triggered the request. This answers "which component fired this request?" and "why is this request being sent twice?"
  • Timing — where the time went (next section).
  • Cookies — the cookies sent and received. Chrome can also show cookies that were blocked, with the reason (for example a SameSite or Secure rule). This is the fastest way to answer "why isn't my cookie being sent?"

The timing breakdown — which phase is slow?

The Timing tab splits a request into phases. Each phase points to a different cause.

PhaseWhat happensIf this phase is long
Queueing / StalledThe browser is waiting to start the requestToo many requests to the same host (HTTP/1.1 allows about 6 connections per host), or lower priority than other requests
DNS LookupResolving the hostnameSlow DNS, or a host the browser has not resolved before
Initial connection / SSLTCP connect and TLS handshakeA new connection to a distant server; connections are not being reused
Request sentUploading the requestA large upload or a slow uplink
Waiting for server responseFrom sending the request until the first byte arrives — often called TTFB (time to first byte)The server is slow. This is a backend problem: slow query, lock, slow upstream call
Content DownloadReceiving the bodyA large response, or a slow connection

This one tab often settles the frontend-vs-backend argument in seconds. If 1.9 of 2 seconds is Waiting for server response, no amount of frontend optimisation will help.

Read the waterfall for chains

Look at the shape of the waterfall, not only individual bars. Requests stacked in a staircase — each starting when the previous one finishes — are a request waterfall: the page fetches the user, then the user's team, then the team's projects, one after another. Each step waits for the previous one. That shape is a design problem: those requests could often run in parallel or be combined.

Reproduce outside the page

Right-click a request to get powerful options:

  • Copy → Copy as cURL (or Copy as fetch) — the exact request, with headers and body, as a command you can run in a terminal or paste into a script. This removes the browser from the question: if the cURL fails the same way, it is not a frontend bug. (Chapter 6 uses this heavily.)
  • Block request URL / Block request domain — make a resource fail on purpose to see how the page copes. Great for testing error states.
  • Override content / Override headers — serve your own version of a response or header locally, without deploying anything. Test "would this fix it?" in seconds.
  • Save all as HAR — export every request in the session as a HAR file (HTTP Archive) for a teammate or a support ticket.

A HAR file contains everything — cookies, auth tokens, request bodies. Treat it like a password. Use the sanitised export if your browser offers it, or strip sensitive headers before sharing.

WebSockets and streams

For a WebSocket request (type websocket, status 101), open the Messages tab to see every frame sent and received, in order. For server-sent events (text/event-stream), the EventStream tab shows each event. If a real-time feature "stops updating", check whether frames are still arriving — that separates "the server stopped sending" from "the client stopped rendering."


Part 3: The Console — More Than a Log Viewer

Read every level, not just red

The Console filters messages by level: Verbose, Info, Warnings, Errors. console.debug output is Verbose, which is hidden by default. If you are sure a log line should appear and it does not, check the level filter first.

Warnings matter. Some of the most useful browser messages are warnings, not errors: an invalid form control that cannot be focused, a cookie rejected for its SameSite setting, a deprecated API. The worked example at the end of this chapter is solved by a warning.

The browser prints things here that your JavaScript can never see: the real reason for a CORS failure, a Content Security Policy violation, a mixed-content block. If fetch rejected with a generic Failed to fetch, the explanation is in the Console.

Check which context you are in

The dropdown at the top of the Console (usually labelled top) selects which JavaScript context your typed code runs in: the main page, an iframe, a web worker, or an extension. If a variable "doesn't exist" in the Console but clearly exists in the app, you may be in the wrong context.

The utilities worth knowing

UtilityWhat it does
$0The element currently selected in the Elements panel
$_The result of the last expression you evaluated
copy(value)Copy any value to the clipboard as text — great for grabbing a large API response
console.table(rows)Show an array of objects as a table
console.trace()Print the current call stack — "who called this?"
console.count(label)Count how many times a line runs — "why does this render 14 times?"
console.time(label) / console.timeEnd(label)Measure how long something takes
getEventListeners($0)List the event listeners attached to the selected element
monitorEvents($0, 'click')Log every click event on that element
queryObjects(Constructor)List every live object created by a constructor — useful for leak hunting

Live expressions (the eye icon at the top of the Console) pin an expression that re-evaluates continuously — for example document.activeElement to watch focus move.

The live-object trap

console.log(obj) does not take a snapshot. The Console stores a reference to the object, and when you expand it later, it shows the object's current values, not the values at the moment of logging.

#
Your code
The object in memory
What the Console shows
1
const cart = { items: 1 }
{ items: 1 }
1 / 6

To log a true snapshot, log a copy: console.log(structuredClone(cart)) or console.log(JSON.stringify(cart)). Better still, use a breakpoint (Part 4), which shows values exactly as they are at that moment.


Part 4: The Sources Panel — Pausing Time

console.log shows you one value at one line, and you must edit and reload to add another. A breakpoint pauses the program, and while it is paused you can inspect every variable, walk the call stack, and step forward one line at a time. Breakpoints are not an advanced technique. They are the default tool, and console.log is the fallback.

Open a file with Cmd/Ctrl+P. Search across every loaded file with Cmd+Option+F (Mac) or Ctrl+Shift+F. If the code is minified, click {} (pretty print). If source maps are working, your original files appear in the file tree.

The kinds of breakpoint — and when each one wins

BreakpointHow to set itUse it when
Line breakpointClick a line numberYou know where to look
Conditional breakpointRight-click a line number → Add conditional breakpointThe line runs a thousand times and you care about one case: order.id === 'A-1042'
LogpointRight-click a line number → Add logpointYou want a console.log without editing the code — works even on a deployed site
debugger; statementWrite it in your codeYou want a breakpoint that travels with the code (remove it before committing)
XHR/fetch breakpointSources → XHR/fetch Breakpoints → URL contains /api/orders"Who is sending this request?" — pauses at the exact call
Event listener breakpointSources → Event Listener Breakpoints → e.g. Mouse → click"What runs when I click this?" — pauses in the handler, even if you have no idea where it lives
DOM breakpointElements → right-click a node → Break on → subtree modifications / attribute modifications / node removal"What code changed or removed this element?"
Exception breakpointSources → Breakpoints → Pause on uncaught exceptions / Pause on caught exceptions"Where is this error thrown?" — and with caught enabled, it finds swallowed errors (Chapter 2) that never reach the Console

Pause on caught exceptions deserves special mention. When something "silently fails", turn it on and reproduce. The debugger stops at the throw inside the try block that was hiding it.

While paused

PaneWhat it shows
ScopeEvery variable in scope right now — local, closure, module, global. You can edit values here to test a theory
WatchExpressions you pin, re-evaluated at every pause
Call StackHow execution got here, including async callers. Click any frame to see its variables
ConsoleTyped expressions run in the paused frame's scope — you can call functions with the real local values

The stepping controls:

  • Resume (F8) — run until the next breakpoint.
  • Step over (F10) — run the current line, without going into functions it calls.
  • Step into (F11) — go into the function call on this line.
  • Step out (Shift+F11) — finish the current function and return to its caller.

Skip the framework: the ignore list

Stepping into a React or library function drops you into thousands of lines you do not care about. Right-click a file or folder → Add script to ignore list. Ignored code is skipped when stepping and collapsed in call stacks, so you only ever stop in your own code. Chrome ignores many known third-party scripts by default.


Part 5: The Application Panel — The State You Can't See

Many "impossible" bugs are caused by state the browser is holding: an old cookie, a stale cache, a service worker serving last week's code. The Application panel shows all of it.

The Cookies view lists every cookie with its attributes. Each attribute is a rule the browser enforces silently:

AttributeThe ruleThe bug it causes
Domain / PathThe cookie is only sent to matching hosts and pathsA cookie set for app.example.com is not sent to api.example.com
Expires / Max-AgeAfter this time the cookie is gone"Users get logged out after exactly 24 hours"
HttpOnlyJavaScript cannot read it (document.cookie won't show it)Not a bug — a security feature. Your code cannot read an HttpOnly auth cookie, by design
SecureOnly sent over HTTPSThe cookie works in production but not on http:// in local dev
SameSiteControls sending on cross-site requests: Strict (never), Lax (the default — only top-level navigations), None (always, but requires Secure)A frontend on one site calling an API on another site gets 401 because the session cookie is not sent

When a request is missing a cookie, open that request in the Network panel's Cookies tab. Chrome can show cookies it filtered out for that request, with the reason. That turns a long guessing session into a single click.

Storage

Local Storage, Session Storage and IndexedDB are browsable and editable here. Two classic bugs live in storage:

  • A value stored by an old version of the app has a shape the new version does not expect. Unexpected token 'u', "undefined" is not valid JSON often comes from here.
  • The bug only happens for returning users, never for you — because you cleared your storage. Reproduce with the old stored data.

Storage → Clear site data wipes everything for the origin in one click. Use it to reproduce the "first visit" experience exactly.

Service workers — the most common "stale app" cause

A service worker is a script the browser keeps running in the background. It can intercept every request and answer from its own cache. That is how offline support works — and it is also how users end up running an old version of your app after a deploy, because the old service worker keeps serving old files.

In Application → Service workers you can see whether one is active and what state it is in, and use three switches:

  • Update on reload — install the newest service worker on every reload while debugging.
  • Bypass for network — skip the service worker entirely and go to the network.
  • Unregister — remove it.

Remember: "Disable cache" in the Network panel does not bypass a service worker. If responses show (ServiceWorker) in the Size column, the network was never touched. (FE-09 Scenario 7 walks through a full "users still see the old version" investigation.)


Part 6: Elements and Performance — Briefly

Elements answers "why does this look wrong?":

  • The Styles pane shows every CSS rule that matched the element. A struck-through declaration lost to a more specific rule; hover it to see which one won.
  • Computed shows the final value of every property — the truth, after all the rules.
  • :hov forces states like :hover and :focus so you can inspect styles that disappear when you move the mouse.
  • The Event Listeners tab lists the handlers attached to an element and links to their code.

Performance answers "what was the main thread doing?". Record while reproducing the slowness, then look at the Main track: wide blocks are long-running work, and tasks over 50 ms are flagged as long tasks (a red corner). Use Bottom-Up to see which functions took the most time overall. The details belong to FE-04 and FE-06; for debugging, the key move is simply to record instead of guessing what is slow.


Part 7: Reproducing the User's Environment

"It works for me" usually means "my environment is different from theirs." DevTools can close most of that gap.

To simulateUse
A phone screenDevice mode (Cmd/Ctrl+Shift+M): viewport size, touch, device pixel ratio, user agent. It is not a real device — it does not reproduce the phone's CPU, memory or browser engine
A slow phonePerformance panel → CPU throttling (4× or 6× slowdown)
A slow or flaky networkNetwork panel → Throttling, or Offline
A different location, time zone or languageCommand Menu → Sensors: geolocation, time zone, locale
Dark mode, reduced motionCommand Menu → Rendering: emulate prefers-color-scheme, prefers-reduced-motion
No JavaScriptCommand Menu → Disable JavaScript — shows what the server-rendered HTML alone looks like

Three habits that eliminate whole categories of causes:

  1. Try a private (incognito) window. It has no extensions and no stored state. If the bug disappears, the cause is an extension (ERR_BLOCKED_BY_CLIENT) or stored data.
  2. Try another browser. Safari behaves differently from Chrome in many ways, and every browser on iPhone uses Safari's engine (WebKit), so "broken on iPhone Chrome" is a Safari bug.
  3. Debug the real device when it matters. Connect an Android phone by USB and open chrome://inspect on your computer to get full DevTools for the phone's browser. For iPhone, enable Web Inspector on the phone and use Safari's Develop menu on a Mac.

Part 8: The Browser Investigation, Step by Step

When a bug shows up in the browser, this order wastes the least time:

  1. Console. Every level, including warnings. Read every message in full.
  2. Network. Find the request that matters. Read its status, who sent the response (headers), and the actual body. Check the Timing tab if it is slow.
  3. Initiator. Jump from the request to the code that sent it.
  4. Pause. Set a breakpoint, a conditional breakpoint, or a logpoint where the evidence points. If you do not know where, use an event-listener, XHR/fetch, DOM or exception breakpoint to let the browser find it for you.
  5. State. If the bug depends on the user, check cookies, storage and service workers in Application.
  6. Environment. Incognito, throttling, another browser, the real device — to match the user's conditions.

The Worked Example — "Clicking Save Does Nothing"

Symptom: A user fills in the account settings form and clicks Save. Nothing happens. No error appears.

1. Console. With the default filter set to Errors only, the Console is empty. Switching the levels to include Warnings reveals one line: An invalid form control with name='company' is not focusable.

2. Network. No request is sent when you click Save. That rules out the backend entirely: the problem is before fetch.

3. Pause. Even though the warning is a strong lead, confirm it. Sources → Event Listener Breakpoints → Mouse → click. Click Save. The debugger pauses inside the form's submit handling. Step over a few lines and you reach if (!form.checkValidity()) return. In the Scope pane, the form's company input has validity.valid === false.

4. Why? In Elements, the company input is inside a section hidden with display: none (it is only shown for business accounts), but it still has the required attribute. The browser's built-in validation refuses to submit a form with an invalid required field — and because the field is hidden, it cannot show the user the usual "please fill in this field" bubble. So it silently refuses, and logs the warning.

5. Root cause and fix. A required field that is hidden is still validated. Remove required (or disable the input) when its section is hidden, and add a test for the personal-account path.

Notice the shape: the answer was in the Console the whole time, at a level that was filtered out. The breakpoint turned a lead into proof.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If a bug involves a redirect or a page navigation
  • If most of a slow request's time is "Waiting for server response"
  • If a request spends a long time "Stalled" or "Queueing"
  • If the Size column shows (memory cache), (disk cache) or (ServiceWorker)
  • If you need to know which code sent a request
  • If something "silently fails"
  • If an element changes or disappears and you don't know why
  • If an expanded object in the Console shows values that seem impossible at that line
  • If a request lacks a cookie
  • If users see an old version after a deploy
  • If the bug disappears in an incognito window
  • If fetch fails with only Failed to fetch

The Rule You Keep Forever

The Network panel is the truth about what was sent and received; the Sources panel is the truth about what the code did. Read the evidence there before trusting your mental model — and when you don't know where to look, let an event, fetch, DOM or exception breakpoint find the line for you.


Vocabulary Map

TermWhat it means
DevToolsThe browser's built-in debugging instruments
Command MenuCmd/Ctrl+Shift+P — run any DevTools feature by name
Preserve logKeep Network and Console entries across navigations
ThrottlingSimulating a slower network or CPU
InitiatorThe code (call stack) that caused a request
TTFBTime to first byte — how long until the server started responding
Request waterfallRequests made one after another because each depends on the previous
Provisional headersHeaders the browser intended to send for a request that did not complete normally
HAR fileAn export of every network request in a session — contains secrets
Copy as cURLExporting a browser request as a terminal command to reproduce it outside the browser
Local overridesServing your own version of a response or header locally, without deploying
BreakpointA place where execution pauses so you can inspect state
Conditional breakpointA breakpoint that pauses only when an expression is true
LogpointA breakpoint that logs a message instead of pausing — no code edit needed
XHR/fetch breakpointPauses when a request to a matching URL is sent
Event listener breakpointPauses when an event of a given type is handled
DOM breakpointPauses when a node's subtree or attributes change, or the node is removed
Exception breakpointPauses when an exception is thrown — uncaught only, or caught too
Call stackThe chain of calls that led to the current line
Step over / into / outRun the line / enter the called function / finish the current function
Ignore listScripts DevTools skips while stepping, usually library code
Execution contextWhich page, frame, worker or extension the Console evaluates in
HttpOnly / Secure / SameSiteCookie rules: hidden from JS / HTTPS-only / restricted on cross-site requests
Service workerA background script that can intercept requests and serve them from its own cache
Device modeEmulating a phone's viewport and user agent — not its CPU or engine
Remote debuggingRunning DevTools on your computer against a browser on a real phone

Practice Problems

Problem 1 — Which panel? For each, name the first panel and the exact feature you would use: (a) "Which component sends this /api/me request twice on page load?" (b) "Something removes the promo banner a second after it appears." (c) "The checkout request takes 4 seconds — is it us or the API?" (d) "The error happens, but nothing appears in the Console."

Problem 2 — Read the timing. A request takes 2.4 s: Stalled 1.8 s, Waiting for server response 0.2 s, Content Download 0.1 s. A teammate wants to optimise the API. What do you tell them, and what do you check instead?

Problem 3 — The missing cookie. Your frontend at app.example.com calls api.example-cdn.net with credentials: 'include'. The API returns 401, but works when you call it directly in the browser's address bar. Using the Application and Network panels, how would you find the cause, and what is the most likely one?

Problem 4 — The impossible log. You log state before an update, and the expanded Console object already shows the updated values. Explain exactly why, and give two ways to see the true value at that line.

Problem 5 — Stale after deploy. After a release, some users still get the old UI even after a hard refresh. Your Network panel shows JS files with (ServiceWorker) in the Size column. Explain what is happening and what you would check and change.


What's Coming Next

The browser shows you one end of a request. Chapter 6 follows the request off the device: DNS, TCP, TLS, HTTP, proxies and caches, one hop at a time. You will take the "Copy as cURL" command from this chapter and use curl's verbose and timing output to see each hop yourself — the fastest way to answer "is it the browser, the network, or the server?"