18 min read

Chapter 2 — Reading Errors and Stack Traces

Every Word in an Error Message Is Evidence

Part 1: Foundations. Prerequisite: Chapter 1.


Opening

An error message is the system telling you, as precisely as it can, what went wrong and where. Most engineers read the first five words, form a theory, and stop reading. The answer is usually in the words they skipped.

This chapter teaches you to read errors literally. You will learn the anatomy of an error object, how to read a stack trace from top to bottom and find the one frame that matters, what each common JavaScript error type means in plain words, how errors get lost on their way to you, and how to read a minified production trace. By the end, TypeError: Cannot read properties of undefined (reading 'map') will not be a crash. It will be a sentence that tells you exactly what to check next.


Part 1: The Anatomy of an Error

In JavaScript, an error is an object. When something goes wrong, the runtime creates one (or your code does with new Error(...)) and throws it. Every error carries up to five pieces of evidence:

FieldExampleWhat it tells you
name (the type)TypeErrorThe category of mistake — Part 3 decodes each one
messageCannot read properties of undefined (reading 'map')The specific problem, in words
stackat OrdersList (OrdersList.tsx:14:27) …Where it happened and how execution got there
cause[cause]: Error: connect ECONNREFUSED 127.0.0.1:5432The underlying error this one wraps (ES2022)
code (Node and many libraries)ECONNREFUSED, ERR_MODULE_NOT_FOUND, 23505A stable, machine-readable identifier — Chapter 3 decodes these

Two more questions to always ask:

  • Where was it thrown? The top of the stack trace.
  • Where was it caught and reported? Often somewhere completely different — a global handler, a logger, an error boundary. The place that printed the error is rarely the place that caused it.

Part 2: Reading a Stack Trace

A stack trace is a snapshot of the call stack at the moment the error was created: which function was running, which function called it, which function called that, and so on.

text
TypeError: Cannot read properties of undefined (reading 'email')
    at sendWelcomeEmail (/app/src/services/email.js:12:31)
    at createUser (/app/src/services/users.js:48:11)
    at async POST (/app/src/routes/users.js:22:18)
    at async Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)

How to read it:

  1. Line 1 is the error itself — type and message. Read all of it.
  2. Each at line is a stack frame. The first frame is where the error was thrown. Each line below is the caller of the line above.
  3. file:line:column — email.js:12:31 means line 12, character 31. Open exactly that spot.
  4. async marks frames reached through an await. V8 reconstructs these so you can see the async caller chain.
  5. node_modules frames are library code. Usually not where the bug is — they are the road the error travelled on.
  6. node:internal/... frames are Node's own internals. Skip them.
  7. [as handle_request] means the function is named handle but was called through the property handle_request.

Find the first frame in your own code. Scan from the top for the first frame whose file is yours, not a library's. Here it is the very first one: sendWelcomeEmail at email.js:12. When a library throws, the first frame might be deep in node_modules; keep reading down until you reach your code — that is the call that passed the library something it could not handle.

The crash site is not the cause site

The stack trace tells you where the program noticed the problem, not where the problem started. In the trace above, email.js:12 tried to read .email from something undefined — say user.profile.email. The trace does not tell you why user.profile was undefined. That value was produced earlier, somewhere else: a database query that did not join the profile, an API response missing a field, a function that returns undefined on one branch.

So the second step is always: trace the bad value backwards. Where did user come from? Where did profile get set? This is data-flow debugging, and it is where most of the real work is.

Truncated traces

V8 records only the top 10 frames by default (Error.stackTraceLimit). Deep framework stacks often cut off before reaching your code. During an investigation you can raise it:

javascript
Error.stackTraceLimit = 50   // at app startup, temporarily

Part 3: The Error Types, Decoded

JavaScript has a small set of built-in error types. Each one is a strong hint about the category of mistake.

TypeError — "you used a value as the wrong kind of thing"

By far the most common. The value exists (or is undefined/null), but you tried to do something its type does not support.

MessageWhat it literally meansFirst thing to check
Cannot read properties of undefined (reading 'map')The thing before .map is undefinedWhy is that value missing? Loading state, wrong response shape, wrong key name
Cannot read properties of null (reading 'x')The thing before .x is nullOften a DOM query (querySelector found nothing) or an explicit null from an API/DB
Cannot set properties of undefined (setting 'x')You assigned to a property of undefinedThe parent object was never created
x is not a functionYou called something that is not a functionWrong import (default vs named), wrong object, a value that was overwritten
x.map is not a functionx exists but is not an arrayThe API returned an object or a string where you expected an array
Assignment to constant variable.You reassigned a constUse let, or do not reassign
Converting circular structure to JSONJSON.stringify hit an object that references itselfYou are serialising something like a request/response object or a DOM node

The same undefined read looks different in each browser — useful when a user report comes from a phone:

  • Chrome / Edge / Node: Cannot read properties of undefined (reading 'name')
  • Firefox: can't access property "name", user is undefined
  • Safari: undefined is not an object (evaluating 'user.name')

ReferenceError — "that name does not exist here"

MessageWhat it means
x is not definedThe variable was never declared in this scope — a typo, a missing import, or a global that does not exist in this environment
window is not defined / document is not definedBrowser-only code ran on the server — during server-side rendering or in a Node script
Cannot access 'x' before initializationYou used a let/const/class before its declaration ran (the temporal dead zone). In module code this is a classic sign of a circular import

SyntaxError — "this text is not valid code or data"

At load time, a SyntaxError means a file could not be parsed at all, so nothing in it ran. At run time, it almost always comes from JSON.parse — including inside response.json().

MessageWhat it really means
Unexpected token '<', "<!DOCTYPE "... is not valid JSONYou received HTML, not JSON. Usually a 404 page, a login redirect, a proxy error page, or the SPA's index.html served for an unknown route. Look at the actual response in the Network tab
Unexpected end of JSON inputThe body was empty (or cut off). Common with 204 No Content responses, or an error response with no body
Unexpected token 'u', "undefined" is not valid JSONSomething stored or sent the string "undefined" — often localStorage.setItem(key, undefined)
Cannot use import statement outside a moduleES-module syntax loaded as CommonJS — a module-system mismatch in config
Unexpected token 'export'Same mismatch in the other direction, often an ESM-only package loaded by a CommonJS tool

RangeError — "the value is outside what is allowed"

MessageWhat it means
Maximum call stack size exceededRecursion with no working base case — a function that calls itself (directly or through others) forever
Invalid time valueYou called .toISOString() (or formatted) an invalid Date, e.g. new Date('not a date')
Invalid array lengthYou tried to create an array with a negative or enormous length

The others you will meet

  • URIError: URI malformed — decodeURIComponent got a broken % sequence, often from a hand-built URL.
  • AggregateError — several errors at once, e.g. from Promise.any when every promise rejects. Look at its .errors array.
  • DOMException — browser API errors, identified by name:
    • AbortError — a request was cancelled with an AbortController (often on purpose, when a component unmounts)
    • NotAllowedError — the browser blocked an action that needs permission or a user gesture: autoplay, clipboard, notifications
    • QuotaExceededError — storage is full (localStorage is usually about 5 MB per origin)
    • SecurityError — the browser refused for security reasons, e.g. storage blocked or cross-origin frame access

The fetch trap

fetch only rejects when no HTTP response arrived at all — the network failed, DNS failed, CORS blocked it, or the request was aborted. A 404 or a 500 is a successful fetch that returned an error status. You must check response.ok yourself.

When fetch does reject, the message depends on the browser and hides the real reason on purpose:

  • Chrome: TypeError: Failed to fetch
  • Firefox: TypeError: NetworkError when attempting to fetch resource.
  • Safari: TypeError: Load failed

The real reason (CORS, DNS, connection refused, mixed content) is only printed in the Console and visible in the Network tab — page JavaScript is deliberately not allowed to see it. In Node, fetch throws TypeError: fetch failed, and the real reason is in error.cause (for example connect ECONNREFUSED 127.0.0.1:3000). Always log the cause.


Part 4: Thrown, Rejected, Uncaught, Unhandled

These four words describe how an error travels, and each one means something different.

Thrown. A synchronous throw unwinds the call stack, frame by frame, until it reaches a try/catch. If none catches it, it becomes uncaught.

Rejected. A promise that fails is rejected. Inside an async function, await turns a rejection back into a throw, so try/catch works around await.

Uncaught exception. A throw that no catch handled.

  • In the browser: the Console shows Uncaught TypeError: …. The current task stops, but the page keeps running — which is why a broken click handler can leave a "half-working" page.
  • In Node: the process prints the stack and exits with code 1. Your server is down until something restarts it.

Unhandled rejection. A rejected promise that nobody attached a handler to.

  • In the browser: Uncaught (in promise) ….
  • In Node 15 and later: the process crashes by default, just like an uncaught exception, with a message that starts This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch().

How errors escape your try/catch

javascript
try {
  saveAuditLog(event)        // async function, but no await
} catch (err) {
  console.error(err)         // never runs — the rejection happens later
}

The try block finishes before the promise rejects, so the rejection escapes and becomes unhandled. The fix is await saveAuditLog(event) — or, if you genuinely want fire-and-forget, saveAuditLog(event).catch(logError).

The same thing happens with callbacks: an error thrown inside a setTimeout callback, an event listener or a stream handler runs on a different call stack from the code that set it up, so the outer try/catch cannot see it.

React error boundaries follow the same rule. They catch errors thrown while rendering, but not errors in event handlers, async code (setTimeout, promises), or server-side rendering. A crash in an onClick will not show your error boundary's fallback UI.


Part 5: How Errors Get Lost on the Way to You

Half of debugging is undoing the damage done to the error before you saw it.

Swallowed errors. The error happened, but code hid it.

javascript
try { await chargeCard(order) } catch {}          // the failure disappears
const user = await getUser(id).catch(() => null)  // "user not found" and "database down" now look the same

When you see "it silently does nothing", search for empty catches and .catch(() => …) first.

Throwing strings. throw 'payment failed' throws a string, which has no stack trace. Always throw Error objects.

Re-wrapping that destroys the stack.

javascript
catch (err) {
  throw new Error(err.message)                                   // original stack lost
  throw new Error('Failed to load invoice', { cause: err })      // original kept as `cause`
}

With cause, Node prints the whole chain, each error with its own stack:

text
Error: Failed to load invoice
    at loadInvoice (/app/src/invoices.js:30:11)
  [cause]: Error: connect ECONNREFUSED 10.0.3.12:5432
      at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)

Read to the bottom. The top error is the most general ("Failed to load invoice"). The bottom [cause] is the most specific and usually the real one ("connection refused to the database").

The {} log. JSON.stringify(err) prints {}, because an Error's message and stack are not enumerable properties. If your logs show error: {}, the logger is serialising errors wrongly. Log err.stack, or use a logger that understands errors.

Logging only err.message. You get the sentence but not the stack and not the cause. Log the whole error.

Promise.all shows only the first failure. It rejects with the first error and discards the others. If several things are failing, use Promise.allSettled to see all of them.


Part 6: Minified Production Traces and Source Maps

In production, your JavaScript is minified (renamed and compressed) and bundled (many files merged into a few). A production stack trace looks like this:

text
TypeError: Cannot read properties of undefined (reading 'map')
    at a (main-3f2a1c.js:1:20554)
    at Xo (framework-8b1d.js:1:61203)

line 1, column 20554 is the tell: the entire bundle is one long line. Function names like a and Xo are minified.

A source map is a file that maps each position in the bundled output back to the original file, line and column. With it, the same trace becomes at OrdersList (components/OrdersList.tsx:14:27).

  • In the browser, DevTools uses source maps automatically when it can load them (the bundle ends with a //# sourceMappingURL= comment).
  • Error-tracking tools (Sentry and similar) let you upload source maps privately — often called hidden source maps — so production traces are readable without publishing your source code.
  • In Node, if you run compiled TypeScript, start Node with --enable-source-maps so traces point at your .ts files instead of the compiled .js.

If a production trace is unreadable, the first fix is not to stare harder at it. It is to get source maps working.


Part 7: Framework Errors You Will Meet in React and Next.js

These appear constantly in modern frontend work. Each one has a specific meaning.

ErrorWhat it meansUsual cause
Hydration failed because the server rendered HTML didn't match the clientThe HTML the server sent differs from what React rendered in the browser, so React threw the server HTML away and re-renderedDate.now(), Math.random(), locale formatting, typeof window branches, invalid HTML nesting (a <div> inside a <p>), or reading a value on the client that the server could not see
Too many re-renders. React limits the number of renders to prevent an infinite loop.A state update ran during render, which triggered another render, foreveronClick={setOpen(true)} instead of onClick={() => setOpen(true)}
Maximum update depth exceededAn effect or update keeps setting state that re-triggers itselfA useEffect that sets state it also depends on
Rendered more hooks than during the previous renderHooks were called conditionallyA hook inside an if, or after an early return
Objects are not valid as a React child (found: object with keys {…})You tried to render a plain object as textRendering {user} instead of {user.name}, or a Date object
Each child in a list should have a unique "key" propA warning: list items have no stable identityMissing key, or using the array index for a list that reorders
Minified React error #418; visit https://react.dev/errors/418Production builds strip long messages and keep a numberOpen the URL — it gives the full message. Then reproduce in development for the full stack

The Worked Example — Reading a Real Error Top to Bottom

A user reports that the invoice page fails. The server log shows:

text
Error: Failed to render invoice page
    at InvoicePage (/app/.next/server/app/invoices/[id]/page.js:1:4410)
  [cause]: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
      at JSON.parse (<anonymous>)
      at parseJSONFromBytes (node:internal/deps/undici/undici:5584:19)
      at async getInvoice (/app/.next/server/chunks/412.js:1:887)

Reading it the way this chapter taught:

  1. Top error: "Failed to render invoice page" — general, just tells you which page. Keep reading.
  2. Cause: SyntaxError … "<!DOCTYPE "... is not valid JSON. The code expected JSON and received HTML.
  3. First frame in our code: getInvoice. That function calls an API and parses the result as JSON.
  4. So the question becomes: why does the invoice API return HTML? Hypotheses: a 404 page (wrong URL), a login page (the server-side call is missing the auth cookie), a proxy error page (the API is down and the gateway returned an HTML error).
  5. Cheapest check: log response.status and response.url before parsing. The status is 200 and the URL ends in /login?next=/api/invoices/42. The request was redirected to the login page because the server-side fetch did not forward the user's session cookie.

The fix is to forward the session to the internal API call — and to check response.ok and the content-type before calling .json(), so the next contract break produces "expected JSON, got text/html (302 → /login)" instead of a confusing SyntaxError.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If you see Cannot read properties of undefined (reading 'x')
  • If x.map is not a function
  • If JSON.parse fails with Unexpected token '<'
  • If you see Unexpected end of JSON input
  • If you see window is not defined
  • If you see Cannot access 'x' before initialization in module code
  • If an async call is not awaited inside try
  • If logs show error: {}
  • If a production trace says main-3f2a1c.js:1:20554
  • If fetch rejected

The Rule You Keep Forever

Read the whole error — type, message, every line of the stack, and every cause to the bottom. The top of the trace tells you where the program noticed the problem; your job is to trace the bad value back to where it started.


Vocabulary Map

TermWhat it means
Error objectA JavaScript object carrying name, message, stack, and optionally cause and code
Throw / raiseCreate an error and unwind the call stack until something catches it
Call stackThe chain of function calls currently in progress
Stack traceA snapshot of the call stack at the moment an error was created
Stack frameOne line of a stack trace — one function call, with file, line and column
Top frameThe frame where the error was thrown
First-party frameA frame in your own code, as opposed to library or runtime code
Error cause / error chainAn error that wraps an underlying error via { cause }, printed as [cause]:
Swallowed errorAn error caught and hidden, so the failure becomes silent
Uncaught exceptionA thrown error no catch handled; crashes a Node process
Rejected promiseA promise that failed
Unhandled rejectionA rejected promise with no handler; crashes Node 15+ by default
Fire-and-forgetStarting an async operation without awaiting it — its errors escape the caller
Error boundaryA React component that catches errors thrown during rendering of its children
Temporal dead zone (TDZ)The period before a let/const/class declaration runs, when accessing it throws
MinificationRenaming and compressing code for production, making traces unreadable
BundleMany source files merged into a few output files
Source mapA file mapping bundled/compiled positions back to original source files
HydrationReact attaching to server-rendered HTML in the browser; a mismatch forces a re-render
Error.stackTraceLimitHow many frames V8 records per stack trace — 10 by default

Practice Problems

Problem 1 — Read it literally. For each message, say in one sentence what is wrong and the first thing you would check: (a) TypeError: Cannot read properties of null (reading 'addEventListener'); (b) TypeError: items.filter is not a function; (c) SyntaxError: Unexpected end of JSON input; (d) ReferenceError: localStorage is not defined.

Problem 2 — Find the frame. A stack trace has 10 frames: the top six are in node_modules/pg, the next is getOrders (src/db/orders.js:41:18), the rest are Express. Which frame do you open first, and what question do you ask when you get there?

Problem 3 — Why didn't the catch work? A function wraps sendEmail(user) in try/catch, but the server still crashes with an unhandled rejection when the email provider is down. Explain exactly why, and give two correct fixes with different behaviour.

Problem 4 — The lost error. Your logs show Payment failed: {} with no other detail. List the two most likely causes in the logging code and how you would fix each.

Problem 5 — The fetch question. A teammate says: "Our fetch never throws when the API returns 500, so error handling is broken in the browser." Are they right? What should the code do instead?


What's Coming Next

JavaScript errors are only the top layer. Underneath them are codes from the operating system, the network stack, the process manager and the database: ECONNRESET, EADDRINUSE, exit code 137, SQLSTATE 40P01. Chapter 3 decodes them. Each one is a precise statement about what physically happened — once you can read them, you will know whether to look at your code, the network, the container or the database before opening a single file.