ehsan.blog
~/blog/how-to-catch-uncaught-browser-errors-and-nextjs-server-errors-automatically — zsh
cat how-to-catch-uncaught-browser-errors-and-nextjs-server-errors-automatically.md

How to catch every uncaught error in a Next.js app, including the ones window.onerror silently misses

·11 min read

You wrap the risky things in try/catch. The fetch call. The JSON parse. The database query.

Then a user reports a blank screen, you open your logs, and there is nothing. No error. No stack. Just a gap where the crash should be.

The error happened in the half you did not wrap. A typo in a click handler. A promise nobody called .catch() on. An image that 404s. None of those go through your try/catch, so none of them reach your logs.

Search for a fix and everyone says the same thing: use window.onerror. That advice is half right, and the missing half costs you real errors. I tested it in a browser to be sure. Here is what I found.

The two globals the browser gives you

The browser will tell you when something crashes and nobody caught it. Two hooks:

ts
// 1. a plain error nobody caught
window.onerror = (message, source, lineno, colno, error) => {
  // "Uncaught TypeError: Cannot read properties of undefined"
}

// 2. a promise that failed and nobody handled it
window.addEventListener('unhandledrejection', (event) => {
  // event.reason is the thing it rejected with
})

Wire those up and you catch more than you did before. Good. But window.onerror has two problems, and both of them are the kind you only find out about during an incident.

Problem 1: assigning it deletes whatever was there

window.onerror = ... is an assignment. Assignments replace.

If your analytics script assigns it, and then your logger assigns it, the analytics one is gone. Not “runs second”. Gone. I ran exactly that:

js
window.onerror = () => { log('LIB-A onerror ran') }
window.onerror = () => { log('LIB-B onerror ran') }

setTimeout(() => { throw new Error('boom') }, 50)

Output:

plaintext
LIB-B onerror ran

LIB-A never ran. It was overwritten and nobody warned anybody.

addEventListener does not work that way. It stacks. Two listeners means both run:

js
window.addEventListener('error', () => log('LISTENER-1 ran'), true)
window.addEventListener('error', () => log('LISTENER-2 ran'), true)
plaintext
LISTENER-1 ran
LISTENER-2 ran

So rule one: use addEventListener('error', ...), never window.onerror = .... Then you are a good citizen. Whatever else is on the page keeps working, and so does the browser’s own console.

Problem 2: it never sees a broken image

This is the one that surprised me.

When an <img> or <script> or <link> fails to load, the browser fires an error event. But it fires that event on the element, and it does not bubble up to window. So window.onerror never hears about it.

Unless you listen in the capture phase. That is the third argument to addEventListener — the true:

js
window.addEventListener('error', handler, true)
//                                        ^^^^ capture phase

Capture means “let me see the event on its way down to the target, before it gets there”. Since the event never travels back up, catching it on the way down is the only way to see it at all.

I tested all three failure kinds on one page. One window.onerror, plus one capture-phase listener:

js
window.onerror = () => log('ONERROR ran')
window.addEventListener('error', (e) => {
  log('CAPTURE target=' + (e.target === window ? 'window' : e.target.tagName) +
      ' src=' + (e.target?.src || '-'))
}, true)

// three different failures
setTimeout(() => { throw new Error('boom') }, 50)
setTimeout(() => { document.body.appendChild(
  Object.assign(document.createElement('script'), { src: '/missing-script.js' })) }, 120)
setTimeout(() => { document.head.appendChild(
  Object.assign(document.createElement('link'), { rel: 'stylesheet', href: '/missing.css' })) }, 160)

Real output:

plaintext
ONERROR ran
CAPTURE target=window src=-
CAPTURE target=LINK src=-
CAPTURE target=SCRIPT src=http://localhost:8791/missing-script.js

Read that carefully. ONERROR ran appears once. The thrown error, yes. The dead script, no. The dead stylesheet, no.

The capture listener got all three.

If your error logging is window.onerror, then a script that fails to load in production — a CDN blip, a bad deploy, an ad blocker eating a chunk — is invisible to you. The page half-works, the user sees a broken UI, and your logs are clean.

The three shapes an error event comes in

That output also shows why one handler cannot just do log(event.error). Look at what you actually get:

What failedevent.errorevent.messageevent.target
Thrown Errorthe Error object"Uncaught Error: boom"window
Dead <script>nothingemptythe <script> element
Dead <link>nothingemptythe <link> element

For a resource failure there is no error and no message. All you have is the element. So a handler that only reads event.error logs undefined and you learn nothing.

You need three branches, in this order:

ts
const onError = (event: ErrorEvent) => {
  // 1. a real Error object — the useful case
  if (event.error !== undefined && event.error !== null) {
    log.error(event.error, {
      source: 'window.onerror',
      location: event.filename
        ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}`
        : undefined,
    })
    return
  }

  // 2. a resource that failed to load
  const target = event.target as (HTMLElement & { src?: string; href?: string }) | null
  if (target && target !== window && target.tagName) {
    log.warn('Resource failed to load', {
      tag: target.tagName.toLowerCase(),
      url: target.src ?? target.href,
    })
    return
  }

  // 3. neither — log the message and whatever coordinates survived
  log.error(event.message || 'Uncaught error', { source: 'window.onerror' })
}

window.addEventListener('error', onError, true)

Two details in there are load-bearing, and my test shows why both are needed.

target.src ?? target.href. A <script> keeps its URL in src. A <link> keeps it in href. Look at the output again: the SCRIPT row has a URL, the LINK row does not, because I only read .src. Read one and you lose half your resource errors.

Keeping location even when you have a real error. filename:line:column looks redundant next to a stack trace. It is not. When the error comes from a script on another domain, browsers censor it for security: the message becomes the literal string "Script error." and the stack is stripped. The event’s own coordinates are what survive. Store them and you still know which file died.

A resource failure is a warn, not an error. A missing image is not a crash. If you log it at error you will train yourself to ignore errors, which is worse than not logging it.

One more thing: stop the handler eating itself

Your error handler logs. Logging can fail. If logging fails it throws, that throw is uncaught, your handler fires again, it logs, that fails…

A one-flag guard stops it:

ts
let capturing = false

const guard = (fn: () => void) => {
  if (capturing) return   // already inside the handler — drop it
  capturing = true
  try { fn() } catch { /* a broken logger must never crash the app */ } finally {
    capturing = false
  }
}

Then run every branch inside guard(...). The flag is set for the whole time the handler is running, so an error thrown by the handler hits the early return and stops there. The empty catch is deliberate: the one thing a logger must never do is become the bug.

In @developerehsan/nextjs-logger all of this is already inside <LoggerProvider> — capture phase, three branches, src ?? href, the re-entrancy guard. Mount the provider and it installs on mount and removes itself on unmount. If you already have global handlers and do not want a second set, turn it off with one prop:

tsx
<LoggerProvider captureGlobalErrors={false}>{children}</LoggerProvider>

Now the server side, which is a different problem

Everything above is the browser. Server errors never get there.

When a Server Component throws, or a Server Action fails, or a Route Handler rejects, Next.js catches it itself to show an error page or return a 500. From the browser’s point of view nothing went uncaught, because Next.js caught it first. window.onerror cannot help you.

Next.js gives you its own hook for these, in a root-level instrumentation.ts:

ts
// instrumentation.ts (project root, next to next.config.js)
export { onRequestError } from '@developerehsan/nextjs-logger/instrumentation'

export async function register() {
  const { registerProcessErrorHandlers } = await import(
    '@developerehsan/nextjs-logger/instrumentation'
  )
  registerProcessErrorHandlers()
}

Two different things happen in that file, and mixing them up is easy.

onRequestError fires for server errors that happened during a request. Next.js hands it the error, the request, and context telling you where it came from — routerKind, routePath, routeType. It catches the class of error that is genuinely hard to see any other way: one thrown mid-stream, after the response already started sending. You cannot turn that into a clean 500 because the bytes are already gone. Without this hook it fails almost silently, and all the user sees is a page that stops halfway.

Two constraints on this handler, and they are not style preferences. It must never throw, and it must never return a promise. Next.js awaits this hook on the failure path of a request that is already failing. Something that rejects or hangs here makes a bad request worse. So the whole body sits in a try/catch with an empty handler, same reason as before.

registerProcessErrorHandlers() covers a completely different gap: errors with no request at all. A setInterval callback that throws. A background job. Code at module load. Those are Node’s uncaughtException and unhandledRejection, and without a listener you get a raw stack on stderr and no structured log of what happened before the process died.

The default that will bite you if you skip the docs

Read this bit before you ship it.

The moment you add a process.on('uncaughtException', ...) listener, you turn off Node’s crash-on-uncaught behaviour. That is not the library’s choice, it is how Node works: if a listener exists, Node hands it the error instead of dying.

So your process keeps running after an error that the language considers unrecoverable. State may be half-written. Locks may be held.

That is usually wrong for a production service, and usually right in development — a logger that killed your dev server every time an async call threw would be unusable. Which is why the default is to stay alive, and why there is a flag to change it:

ts
registerProcessErrorHandlers({ exitOnUncaught: true })

In production, turn that on and let your process manager restart a clean one. A restarted process is safer than a poisoned one.

Note the levels too, because they are chosen deliberately. uncaughtException logs at fatal — the process is in an undefined state, that is the top of the scale. unhandledRejection logs at error, because a floating rejected promise is bad but the process is still coherent. Alert on fatal. Read error in the morning.

Test it now, not during an incident

You do not need a real bug to check the wiring. Three throwaway snippets cover all three paths.

Client, thrown error:

tsx
'use client'
export function CrashButton() {
  return <button onClick={() => { throw new Error('test uncaught error') }}>crash</button>
}

Client, unhandled rejection — one line in the console:

js
Promise.reject(new Error('test rejection'))

Client, resource failure — the one window.onerror misses. Paste in the console:

js
document.body.appendChild(
  Object.assign(document.createElement('img'), { src: '/definitely-not-here.png' })
)

Server:

ts
// app/api/boom/route.ts
export async function GET() { throw new Error('test server error') }

Hit each one. All four should land in your terminal without a single try/catch written. If the image one does not show up, your handler is not in capture phase.

I keep these behind a dev-only debug route on any new project, so I find the gap while I am looking for it, not during an outage.

What still will not be caught

“Automatic error capture” sounds like more coverage than it is. Be clear with yourself about the edges:

  • Errors before the handlers exist. registerProcessErrorHandlers() runs inside register(). Something that throws at module load, before register() executes, has no listener yet. Same on the client: an error thrown before <LoggerProvider> mounts has nothing installed.
  • Anything that never throws. A request that hangs forever. A function that returns a wrong-but-valid answer. No error object exists, so no handler fires. That is a job for timeouts, health checks and assertions, not error handling.
  • Cross-origin detail. You will get the event, but as covered above the message may be the useless string "Script error." with no stack. Serving your scripts from your own domain, or setting crossorigin plus proper CORS headers, is what gets the detail back.

Global handlers guarantee visibility. They do not handle anything. You still want try/catch and error boundaries wherever you actually want to recover — retry the request, show a fallback, keep the user moving. Two different jobs:

  • try/catch and error boundaries — where you want to do something about it.
  • global handlers — where you want to know about it, including the cases nobody planned for.

The short version

  • Never window.onerror = .... The next library to load deletes yours.
  • Use window.addEventListener('error', handler, true). The true is not optional — without it you never see a failed script, stylesheet or image.
  • Branch on event.error, then event.target.tagName, then event.message. Resource failures give you no error and no message, only the element.
  • Read target.src ?? target.href. <script> uses one, <link> uses the other.
  • Keep filename:line:column next to the stack. On a cross-origin error it is all that survives.
  • Guard the handler with a re-entrancy flag so a broken logger cannot loop.
  • Add unhandledrejection too. It is a separate event and error never covers it.
  • On the server, onRequestError for request errors and registerProcessErrorHandlers() for everything outside a request.
  • Set exitOnUncaught: true in production. Adding an uncaughtException listener silently stops Node from crashing, and a poisoned process is worse than a restarted one.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.