How to catch every uncaught error in a Next.js app, including the ones window.onerror silently misses
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:
// 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:
window.onerror = () => { log('LIB-A onerror ran') }
window.onerror = () => { log('LIB-B onerror ran') }
setTimeout(() => { throw new Error('boom') }, 50)Output:
LIB-B onerror ranLIB-A never ran. It was overwritten and nobody warned anybody.
addEventListener does not work that way. It stacks. Two listeners means both run:
window.addEventListener('error', () => log('LISTENER-1 ran'), true)
window.addEventListener('error', () => log('LISTENER-2 ran'), true)LISTENER-1 ran
LISTENER-2 ranSo 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:
window.addEventListener('error', handler, true)
// ^^^^ capture phaseCapture 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:
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:
ONERROR ran
CAPTURE target=window src=-
CAPTURE target=LINK src=-
CAPTURE target=SCRIPT src=http://localhost:8791/missing-script.jsRead 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 failed | event.error | event.message | event.target |
|---|---|---|---|
Thrown Error | the Error object | "Uncaught Error: boom" | window |
Dead <script> | nothing | empty | the <script> element |
Dead <link> | nothing | empty | the <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:
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:
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:
<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:
// 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:
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:
'use client'
export function CrashButton() {
return <button onClick={() => { throw new Error('test uncaught error') }}>crash</button>
}Client, unhandled rejection — one line in the console:
Promise.reject(new Error('test rejection'))Client, resource failure — the one window.onerror misses. Paste in the console:
document.body.appendChild(
Object.assign(document.createElement('img'), { src: '/definitely-not-here.png' })
)Server:
// 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 insideregister(). Something that throws at module load, beforeregister()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 settingcrossoriginplus 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/catchand 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). Thetrueis not optional — without it you never see a failed script, stylesheet or image. - Branch on
event.error, thenevent.target.tagName, thenevent.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:columnnext 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
unhandledrejectiontoo. It is a separate event anderrornever covers it. - On the server,
onRequestErrorfor request errors andregisterProcessErrorHandlers()for everything outside a request. - Set
exitOnUncaught: truein production. Adding anuncaughtExceptionlistener silently stops Node from crashing, and a poisoned process is worse than a restarted one.