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:
| Field | Example | What it tells you |
|---|---|---|
name (the type) | TypeError | The category of mistake — Part 3 decodes each one |
message | Cannot read properties of undefined (reading 'map') | The specific problem, in words |
stack | at OrdersList (OrdersList.tsx:14:27) … | Where it happened and how execution got there |
cause | [cause]: Error: connect ECONNREFUSED 127.0.0.1:5432 | The underlying error this one wraps (ES2022) |
code (Node and many libraries) | ECONNREFUSED, ERR_MODULE_NOT_FOUND, 23505 | A 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.
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:
- Line 1 is the error itself — type and message. Read all of it.
- Each
atline is a stack frame. The first frame is where the error was thrown. Each line below is the caller of the line above. file:line:column—email.js:12:31means line 12, character 31. Open exactly that spot.asyncmarks frames reached through anawait. V8 reconstructs these so you can see the async caller chain.node_modulesframes are library code. Usually not where the bug is — they are the road the error travelled on.node:internal/...frames are Node's own internals. Skip them.[as handle_request]means the function is namedhandlebut was called through the propertyhandle_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:
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.
| Message | What it literally means | First thing to check |
|---|---|---|
Cannot read properties of undefined (reading 'map') | The thing before .map is undefined | Why is that value missing? Loading state, wrong response shape, wrong key name |
Cannot read properties of null (reading 'x') | The thing before .x is null | Often 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 undefined | The parent object was never created |
x is not a function | You called something that is not a function | Wrong import (default vs named), wrong object, a value that was overwritten |
x.map is not a function | x exists but is not an array | The API returned an object or a string where you expected an array |
Assignment to constant variable. | You reassigned a const | Use let, or do not reassign |
Converting circular structure to JSON | JSON.stringify hit an object that references itself | You 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"
| Message | What it means |
|---|---|
x is not defined | The 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 defined | Browser-only code ran on the server — during server-side rendering or in a Node script |
Cannot access 'x' before initialization | You 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().
| Message | What it really means |
|---|---|
Unexpected token '<', "<!DOCTYPE "... is not valid JSON | You 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 input | The 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 JSON | Something stored or sent the string "undefined" — often localStorage.setItem(key, undefined) |
Cannot use import statement outside a module | ES-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"
| Message | What it means |
|---|---|
Maximum call stack size exceeded | Recursion with no working base case — a function that calls itself (directly or through others) forever |
Invalid time value | You called .toISOString() (or formatted) an invalid Date, e.g. new Date('not a date') |
Invalid array length | You tried to create an array with a negative or enormous length |
The others you will meet
URIError: URI malformed—decodeURIComponentgot a broken%sequence, often from a hand-built URL.AggregateError— several errors at once, e.g. fromPromise.anywhen every promise rejects. Look at its.errorsarray.DOMException— browser API errors, identified byname:AbortError— a request was cancelled with anAbortController(often on purpose, when a component unmounts)NotAllowedError— the browser blocked an action that needs permission or a user gesture: autoplay, clipboard, notificationsQuotaExceededError— storage is full (localStorageis 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
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.
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.
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:
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:
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-mapsso traces point at your.tsfiles 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.
| Error | What it means | Usual cause |
|---|---|---|
Hydration failed because the server rendered HTML didn't match the client | The HTML the server sent differs from what React rendered in the browser, so React threw the server HTML away and re-rendered | Date.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, forever | onClick={setOpen(true)} instead of onClick={() => setOpen(true)} |
Maximum update depth exceeded | An effect or update keeps setting state that re-triggers itself | A useEffect that sets state it also depends on |
Rendered more hooks than during the previous render | Hooks were called conditionally | A 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 text | Rendering {user} instead of {user.name}, or a Date object |
Each child in a list should have a unique "key" prop | A warning: list items have no stable identity | Missing key, or using the array index for a list that reorders |
Minified React error #418; visit https://react.dev/errors/418 | Production builds strip long messages and keep a number | Open 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:
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:
- Top error: "Failed to render invoice page" — general, just tells you which page. Keep reading.
- Cause:
SyntaxError … "<!DOCTYPE "... is not valid JSON. The code expected JSON and received HTML. - First frame in our code:
getInvoice. That function calls an API and parses the result as JSON. - 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).
- Cheapest check: log
response.statusandresponse.urlbefore parsing. The status is200and the URL ends in/login?next=/api/invoices/42. The request was redirected to the login page because the server-sidefetchdid 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
- If you see
Cannot read properties of undefined (reading 'x') - If
x.map is not a function - If
JSON.parsefails withUnexpected token '<' - If you see
Unexpected end of JSON input - If you see
window is not defined - If you see
Cannot access 'x' before initializationin 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
fetchrejected
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
| Term | What it means |
|---|---|
| Error object | A JavaScript object carrying name, message, stack, and optionally cause and code |
| Throw / raise | Create an error and unwind the call stack until something catches it |
| Call stack | The chain of function calls currently in progress |
| Stack trace | A snapshot of the call stack at the moment an error was created |
| Stack frame | One line of a stack trace — one function call, with file, line and column |
| Top frame | The frame where the error was thrown |
| First-party frame | A frame in your own code, as opposed to library or runtime code |
| Error cause / error chain | An error that wraps an underlying error via { cause }, printed as [cause]: |
| Swallowed error | An error caught and hidden, so the failure becomes silent |
| Uncaught exception | A thrown error no catch handled; crashes a Node process |
| Rejected promise | A promise that failed |
| Unhandled rejection | A rejected promise with no handler; crashes Node 15+ by default |
| Fire-and-forget | Starting an async operation without awaiting it — its errors escape the caller |
| Error boundary | A 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 |
| Minification | Renaming and compressing code for production, making traces unreadable |
| Bundle | Many source files merged into a few output files |
| Source map | A file mapping bundled/compiled positions back to original source files |
| Hydration | React attaching to server-rendered HTML in the browser; a mismatch forces a re-render |
Error.stackTraceLimit | How 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.