Server-side debugging with the inspector
Attach the Node V8 inspector to your local Next.js server, the third diagnostic surface alongside Sentry and structured logs.
A user clicks a button and the toast shows the generic “Something went wrong.” You open Sentry: a clean stack trace points straight at a validation predicate in a server action. You copy the requestId, filter the log drain by it, and read the per-request narrative: the input looked valid, and the predicate still returned false. Both surfaces agree on where the failure is. Neither can tell you why an input that looks correct fails the check.
That is the wall. Both surfaces show only what was captured, and nobody captured the value that explains this bug, because nobody knew in advance it would matter. So you stop reading recordings and open a live window: attach a debugger to your local server, pause on that line, replay the same input, and read the variables as the code runs. Sentry answers what threw, the logs answer what happened, the debugger answers what’s in scope right now. Three surfaces, three questions, one incident.
By the end you will have a .vscode/launch.json that starts a debuggable Next.js server in one keypress, the three breakpoint moves you reach for in practice (plain, conditional, and logpoint), and a drill that wires this chapter’s tools into a single incident.
When the debugger earns its weight
Section titled “When the debugger earns its weight”When a bug gets slippery, the wrong instinct is to reach for the debugger first. Getting this decision right matters more than any breakpoint mechanic.
Logs and Sentry breadcrumbs are retroactive: you see only the values captured at the time, with no way to ask a follow-up. The debugger is interactive: you can read every local, closure, and property in scope, even mutate state and resume to test a hypothesis. That power is why it’s the last tool, not the first. Sentry plus structured logs resolve roughly nine incidents in ten; the debugger is for the tenth, and reaching for it earlier just means you skipped the cheaper surfaces.
Three signals say it earns its weight:
- The bug doesn’t reproduce in the logs. The deciding state was on the wire, a value that flowed through the request but never landed in a logged variable. You can’t log a value you didn’t know mattered, but you can pause and read it.
- The call stack points into a library doing something you don’t expect. The throw is two frames deep into a dependency, and you need the arguments your code handed it.
- It’s a heisenbug , where the failure vanishes the instant you add a
console.log. A breakpoint observes without recompiling or shifting the timing the way an edit-and-restart does.
Three more cases point elsewhere: a known bug you can reproduce wants a durable failing test, not a session that evaporates when you close the tab; anything in production is the line you never cross (the last section covers why); and performance work is a profiler’s job, covered later in this unit.
The walk below follows the order these questions come in; the skill is in the order, not any single answer.
Production is read-only for diagnosis: reconstruct the failure from the captured event and the per-request narrative, joined by requestId.
An inspector on a live server is a remote-code-execution surface, for the reason at the end of this lesson.
Once you can reproduce it and know why, the durable artifact is a regression test. A debugging session evaporates when you close the tab; the test stays and guards the fix.
CPU, memory, and async-timing questions are a profiler’s domain, not a breakpoint’s, and get their own treatment later in this unit.
The deciding state was on the wire, not in any log, so pause on the suspicious line, replay the same input, and read it live. This is the one case the debugger is built for.
How the Node inspector works
Section titled “How the Node inspector works”The debugger is not a feature of your editor. It is a feature of the runtime, and your editor merely attaches to it.
Node ships with a built-in V8 inspector. Start any Node process with the --inspect flag and it opens a WebSocket, by default on 127.0.0.1:9229, that speaks the Chrome DevTools Protocol , or CDP. Any CDP-speaking client can attach over that socket: VS Code, Chrome DevTools, WebStorm, Firefox. One protocol, many clients, which is why Chrome DevTools is a drop-in alternative to VS Code later in this lesson.
node --inspect :9229 — CDP over WebSocket
Two flag variants are worth knowing. --inspect runs your program normally but leaves it attachable; it is the one you want almost always. --inspect-brk pauses on the very first line and waits for a client before running anything, useful for debugging startup but rarely what you need for app code, and it has a sharp edge we hit in the next section.
The debugger reads the same dev source maps the bundler already produces, the ones Sentry used to rebind your minified production stack traces in this chapter’s first lesson. That is how a breakpoint on a line of your .ts source maps to the transpiled code Node actually runs.
One thing to plant now: a production Node process never runs with --inspect. There, an open inspector port is not a convenience but a remote-code-execution surface, as the last section explains.
Starting Next.js with the inspector
Section titled “Starting Next.js with the inspector”To debug server code you need a Next.js dev server listening for a debugger. As of Next.js 16.1 there is a first-class flag for exactly this.
pnpm dev --inspectThe course default. The flag passes --inspect through to only the Node process running your code.
npm run dev -- --inspectThe lone -- is load-bearing. Without it, npm treats the flag as its own instead of forwarding it to the dev script, the most common reason “the flag didn’t work.”
yarn dev --inspectSame shape as pnpm. The flag forwards straight through to your server’s Node process.
Why a dedicated flag instead of NODE_OPTIONS=--inspect? NODE_OPTIONS attaches the inspector to every Node process the dev command spawns, so they fight over the port. The --inspect flag threads it through to only your server.
However you start it, look for one confirmation line in the terminal:
Debugger listening on ws://127.0.0.1:9229/3f8b1c2a-...For help, see: https://nodejs.org/en/docs/inspector ▲ Next.js 16.2.0 - Local: http://localhost:3000No Debugger listening line means the flag didn’t take, and on npm the missing -- is almost always why. That 9229 is the same port any client attaches to; if it’s already in use, override it with pnpm dev --inspect=9230.
The VS Code launch config
Section titled “The VS Code launch config”Attaching by hand works, but you do it often enough to be worth a one-keypress button: a .vscode/launch.json file, committed so the whole team gets it. The shape below is the current official Next.js config. Read it field by field, since the most important field is also the one that changed recently.
{ "version": "0.2.0", "configurations": [ { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm dev --inspect", "skipFiles": ["<node_internals>/**"] }, { "name": "Next.js: debug client-side", "type": "chrome", "request": "launch", "url": "http://localhost:3000" }, { "name": "Next.js: debug full stack", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev", "--inspect"], "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", "action": "debugWithChrome" } } ]}The server-side config, the load-bearing one for this lesson. It launches your dev command and attaches the debugger to the server process for you.
{ "version": "0.2.0", "configurations": [ { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm dev --inspect", "skipFiles": ["<node_internals>/**"] }, { "name": "Next.js: debug client-side", "type": "chrome", "request": "launch", "url": "http://localhost:3000" }, { "name": "Next.js: debug full stack", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev", "--inspect"], "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", "action": "debugWithChrome" } } ]}The field that changed, and the one to really understand. node-terminal runs your dev command in VS Code’s integrated terminal and auto-attaches the debugger to the Node process it spawns, so you never think about ports. Older posts use a "request": "attach" config pointed at 9229, which makes you start the server in one place and attach in another; this collapses both into one button.
{ "version": "0.2.0", "configurations": [ { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm dev --inspect", "skipFiles": ["<node_internals>/**"] }, { "name": "Next.js: debug client-side", "type": "chrome", "request": "launch", "url": "http://localhost:3000" }, { "name": "Next.js: debug full stack", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev", "--inspect"], "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", "action": "debugWithChrome" } } ]}The command VS Code runs, the same line from the previous section. --inspect makes the spawned process attachable, and node-terminal does the attaching.
{ "version": "0.2.0", "configurations": [ { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm dev --inspect", "skipFiles": ["<node_internals>/**"] }, { "name": "Next.js: debug client-side", "type": "chrome", "request": "launch", "url": "http://localhost:3000" }, { "name": "Next.js: debug full stack", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev", "--inspect"], "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", "action": "debugWithChrome" } } ]}Keeps “step into” shallow. Without it, stepping into a call dives straight into Node’s internals; <node_internals>/** tells the debugger to step over runtime code so you stay in your own. Add "**/node_modules/**" to skip dependencies too.
{ "version": "0.2.0", "configurations": [ { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm dev --inspect", "skipFiles": ["<node_internals>/**"] }, { "name": "Next.js: debug client-side", "type": "chrome", "request": "launch", "url": "http://localhost:3000" }, { "name": "Next.js: debug full stack", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev", "--inspect"], "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", "action": "debugWithChrome" } } ]}The other two official entries. “Client-side” launches Chrome against your dev URL to debug browser code; “full-stack” runs both at once via serverReadyAction. Copy the whole file; this lesson stays on server-side.
The old attach config still has a place: reach for it when you would rather start pnpm dev --inspect in your own terminal and then attach to the running process.
{ "name": "Next.js: attach to running server", "type": "node", "request": "attach", "port": 9229, "skipFiles": ["<node_internals>/**"]}With the file saved, the run procedure is four steps:
-
Open the Run and Debug panel:
⇧⌘Don macOS,Ctrl+Shift+Don Windows and Linux. -
Pick Next.js: debug server-side from the configuration dropdown at the top.
-
Press
F5to launch it. -
Confirm the
Debugger listening on ws://127.0.0.1:9229/...line appears in the integrated terminal. That’s your signal the inspector is attached.
Breakpoints, conditional breakpoints, and logpoints
Section titled “Breakpoints, conditional breakpoints, and logpoints”Three core moves build on each other: a plain pause, a pause with a condition, and a print that never pauses. A fourth, the debugger; statement, is a fallback for cases the first three can’t reach.
The plain breakpoint
Section titled “The plain breakpoint”Click in the gutter, the narrow strip just left of the line numbers, next to a line inside a server action or a lib/ helper. A red dot appears, and its color tells you whether it will fire. A solid red dot is bound : the source map resolved your line to running code. A hollow grey dot is unbound and won’t fire, a case we’ll handle shortly.
Now trigger the request that runs that line. Execution pauses on the dot, and VS Code fills four panels:
- Variables holds every local in the current frame, plus the closure scope and
this, what’s actually in memory. - Watch holds expressions you pin so they re-evaluate on every step.
- Call Stack shows how execution got here, frame by frame. Because of
skipFiles, thenode_modulesframes stay collapsed, so you see your path, not the framework’s. - Debug Console is the most powerful of the four.
The Debug Console is a REPL bound to the paused frame’s scope, so you can evaluate against whatever is in scope. Walk an object with user.role, drill into input.lineItems[0], or run a real query against the live database connection:
await db.query.invoices.findFirst({ where: eq(invoices.id, 'inv_123'),});That answers “what’s in scope right now” faster than adding a log line and restarting the server. The Debug Console supports top-level await, so the query resolves to the row rather than a pending Promise.
Two limits are worth knowing. A Drizzle query object isn’t SQL yet, since the SQL is generated lazily when the query runs, so set your breakpoint around the call and inspect the result, not the query. And if a breakpoint pauses on the wrong line, Fast Refresh has probably staled the file the debugger maps against; restart the dev server to realign it.
The conditional breakpoint
Section titled “The conditional breakpoint”A plain breakpoint pauses every time the line runs, a problem in a loop or a hot path: you’ll pause thousands of times to reach the one iteration that matters. Add a condition instead. Right-click the gutter, choose Edit Breakpoint, and type an expression:
user.id === 'usr_problem'Now it fires only when that’s true. This is the move for an intermittent or per-user bug: set the condition to the user or attempt that fails (attempt > 3), let every other request through, and pause exactly when the bad one arrives.
The logpoint
Section titled “The logpoint”Sometimes you don’t want to pause at all, because pausing on a hot path breaks the timing you’re trying to observe. A logpoint is a breakpoint that prints and keeps running. Right-click the gutter, choose Add Logpoint, and write a message with expressions in braces:
input was {input.amount}, user is {user.id}Each time the line runs, that message prints to the Debug Console with the values filled in, and execution never stops, a log line you add without editing the file or restarting the server. Reach for it on hot paths, or any time you just want a value to fly by.
One discipline: a logpoint is not a substitute for your logger. Logpoints live in your editor, never in the code, and vanish when you close the session. Real diagnostics an operator needs at 3am go through the structured pino logger from earlier in this chapter, never console.log (which the no-console lint rule blocks in server code for this reason).
The debugger; statement
Section titled “The debugger; statement”A debugger; line in your code pauses execution whenever a debugger is attached and does nothing otherwise. Reach for it when a gutter breakpoint won’t bind (a Turbopack source-map miss leaves a hollow grey dot) or when the location is dynamically generated and you can’t click a stable line. After adding it, restart the dev server so the source maps regenerate; only as a last resort fall back to next dev --webpack, an escape hatch rather than a habit.
Treat debugger; as strictly temporary. CI greps for it and fails the build if you commit it, the same guardrail that catches a stray console.log.
Here is the moment the toolkit pays off, scrubbed step by step: a breakpoint hits the validation predicate from the intro, and the Variables panel reveals the value no log line carried.
export function belongsToOrg( input: { orgId: string }, ) { if (input.orgId === scopedOrgId) { return true; } return false; } export function belongsToOrg( input: { orgId: string }, ) { if (input.orgId === scopedOrgId) { return true; } return false; } input.orgId scopedOrgId input.orgId === scopedOrgId export function belongsToOrg( input: { orgId: string }, ) { if (input.orgId === scopedOrgId) { return true; } return false; } And the real thing in VS Code, so you recognize it when you get there.
export function belongsToOrg( input: { orgId: string }, ) { if (input.orgId === scopedOrgId) { return true; } return false; } input.orgId === scopedOrgId Now lock in which move fits which situation.
Select every pairing below where the breakpoint move correctly fits the situation. Pick all that apply.
debugger; line instead.debugger; statement is the fallback when a gutter dot won’t bind. The two decoys point at the wrong surface. A bug you can already reproduce and understand wants a durable regression test, not a breakpoint that evaporates when you close the tab. And a “where does the time go” question is a profiler’s job; logpoints can’t give you a reliable time budget.The server-action-failed drill, end to end
Section titled “The server-action-failed drill, end to end”The skill is descending the diagnostic ladder in order, stopping at each rung only when it runs dry. Walk it with the failing scenario from the intro.
-
Sentry, what threw. Open the event. The stack trace is rebound to source by the maps you uploaded at build time; it names the failing server action and the exact line, a validation predicate. You know where.
-
Logs, what happened. Copy the
requestIdfrom the Sentry event’s request context (it lives in context, not as a tag, because the AsyncLocalStorage value you wired earlier is shared between Sentry and the logger). Filter the drain by it and read the per-request narrative: the input looked valid, yet the predicate returnedfalse. The two surfaces agree on where but contradict your intuition about why. That disagreement is your signal to descend. -
Reproduce locally. Start
pnpm dev --inspect, launch the Next.js: debug server-side config, set a breakpoint on the predicate’sreturn false, and replay the same input. The log line gave you its exact shape, so you reproduce the real failure. -
Read live state, what’s in scope right now. The breakpoint hits. The Variables panel shows the predicate comparing the input’s org against a closure-captured tenant scope holding the wrong
orgId, a value no log line carried because nobody logs a closure’s captured scope. That gap is exactly what the debugger closes. -
Fix and close the loop. The fix is one line in
lib/. Then add a regression test: the bug is now “a known bug with a clean reproduction,” and the test, not the debugging session, is the durable artifact that keeps it fixed.
The three surfaces interleave rather than compete, and the order is fixed: Sentry’s stack, then the log narrative, then the debugger, descending only when the rung above runs dry.
requestIdChrome DevTools as the alternative client
Section titled “Chrome DevTools as the alternative client”One protocol, many clients: everything you just did in VS Code works in Chrome DevTools too, with the same inspector, source maps, and caveats, handy when you’re debugging without an editor open.
-
With
pnpm dev --inspectrunning, openchrome://inspectin Chrome. -
Your Node process appears under Remote Target. Click inspect next to it; a DevTools window opens, attached to the server.
-
Go to the Sources tab and set gutter breakpoints exactly as you would in VS Code.
One shortcut: when a server error hits in development, the Next.js error overlay carries a small Node.js icon. Clicking it copies the inspector’s DevTools URL; paste that into a new tab and you land inside the running server process at the point of the error, the fastest path from a thrown error to a live inspector.
One file-finding gotcha: your server source files appear under paths like webpack://_N_E/./app/actions.ts. That webpack:// prefix shows up even under Turbopack, a naming artifact, not a sign you’re on the wrong bundler. Use ⌘P (or Ctrl+P) to fuzzy-find a file by name instead of hunting through the tree.
Why you never attach the inspector to production
Section titled “Why you never attach the inspector to production”The --inspect flag is dev-only, full stop. That same await db.query(...) you ran in the Debug Console is the whole problem: the inspector port speaks CDP, and CDP can evaluate arbitrary code in the running process. Locally that is a superpower. On a deployed server it is remote code execution against your live database: anyone who can reach an open 9229 can run that query, or any other code, inside your production process.
Vercel enforces the rule by construction: it doesn’t expose the inspector port, and its serverless functions are short-lived and never attachable, so there is no long-running process to point a debugger at. Production debugging stays the Sentry-plus-drain workflow; the inspector is the local complement.
External resources
Section titled “External resources”The official guides go deeper on the launch configs, the alternative clients, and the inspector primitive.