HTTP Error Fixes in Node.js and Next.js
Node.js apps fail in their own ways. A single unhandled error can stop the whole process, so one bad request turns into a 502 for everyone until something restarts it. Frameworks such as Next.js can send a friendly "not found" page with a 200 code, which search engines read as a real page.
And hosted platforms cut functions off at a time limit you may not know you had. This guide is for developers and site owners running Express, Next.js or similar apps, and it covers sending the right status codes, handling errors without crashing, and running the process so it recovers.
Why Node apps fail differently
A PHP site starts a fresh worker for each request, so a fatal error usually spoils only that one page. A Node app is one long-running process that handles every request. That design is fast, but it changes what errors do:
- One uncaught error can stop everything. If an exception escapes your handlers, Node's default is to print it and exit. Until a process manager starts it again, nothing is listening and the proxy in front returns 502 for every URL.
- Status codes are whatever your code says. Many frameworks default to 200. If a route renders "Product not found" without setting the status, the page is a soft 404.
- Rendering can start before errors happen. With streamed server rendering, the 200 status and the top of the page may already be on their way when a component deeper down fails, so the code cannot be changed any more.
- Hosting platforms add their own limits. Serverless and edge functions have maximum run times and memory. Going over produces an error from the platform, not your app.
Because the audit sees the outside of the app, the first job is always to compare what the HTTP Error Audit reports with what your code thinks it is doing.
The fix, step by step
Step 1
Check the status codes your app really sends
Run the HTTP Error Audit and note every 404, 500 and 502. Then test
the cases a crawl rarely reaches: request a made-up path such as /no-such-page-4821, a product
or post ID that does not exist, and an API route with bad input, each with curl -s -o /dev/null -w "%{http_code}\n".
Write down the code each one returns. Any "not found" page answering 200, or any error answering 200
with an error message in the body, needs fixing in the next steps.
Step 2
Return a real 404 for unknown routes and missing records
Two kinds of 404 need handling. Unknown paths need a catch-all at the end
of your routing, such as a final app.use() in Express that sets res.status(404)
and renders your not-found page. Known routes with a missing record, such as /products/:slug
where the slug is not in the database, need the handler itself to check the lookup result and return
404 before rendering. The second kind is the one most apps get wrong, and it is invisible unless you
test it.
Step 3
Catch errors in async handlers and answer with a 500
In Express 4, an error thrown inside an async route handler
is not passed to your error handler; the request hangs or the rejection goes unhandled. Wrap async handlers
so that failures call next(err), or move to Express 5, which forwards rejected promises
for you. Then add one error-handling middleware with four arguments at the very end that logs the full
error, sets res.status(err.status || 500), and sends a short, safe message without the stack
trace.
Step 4
Add Next.js error and not-found files
In the App Router, call notFound() from next/navigation whenever
data for a page is missing, and create app/not-found.tsx for the page shown. Add app/error.tsx,
which must be a client component, for errors inside a route, and app/global-error.tsx for
failures in the root layout. In the Pages Router, return { notFound: true } from getServerSideProps
or getStaticProps, and add pages/404.tsx and pages/500.tsx. Fetch
the data before any streaming starts.
Step 5
Stop unhandled rejections from killing the process
Search the code for promises that are neither awaited inside a try
block nor given a .catch(), especially fire-and-forget calls such as sending an email or
writing an analytics event after responding. In current Node versions an unhandled rejection ends the
process by default. Handle each one where it happens. Add process.on('unhandledRejection')
and process.on('uncaughtException') handlers only to log the error and exit cleanly, leaving
the restart to your process manager rather than trying to carry on.
Step 6
Run the app under PM2 and survive a reboot
If you host the app yourself, start it with PM2 so a crash is followed by an automatic
restart, and check the restart count with pm2 list, where a rising number means crashes
are still happening. Run pm2 startup, follow the command it prints, then pm2 save,
so the app starts again after the server reboots. A site that returns 502 on every page after a reboot
or overnight maintenance nearly always skipped this. Set max_memory_restart to recycle a
leaking process.
Step 7
Build the new release before swapping it in
Never run npm install and next build in the folder the live process
is serving from. If the build fails half way, the running app can lose files it needs, and a restart
finds no production build and exits, producing 500s or 502s. Build each release in its own folder, run
it or a smoke test against it, then switch a symlink and use pm2 reload to swap processes
one at a time. Keep the previous release so rolling back takes seconds.
Step 8
Keep requests inside platform time limits
On Vercel, Netlify and similar hosts, check the current maximum run time for functions
on your plan, and read the function logs for timeout messages next to the URLs the audit flagged. Where
your platform allows it, raise the limit for the slow route, for example with export const maxDuration
in a Next.js route on Vercel. Better still, cache the result, or move slow work such as report generation
to a background job and return quickly. Then rerun the HTTP Error Audit to confirm.
A safe Express error set-up
This pattern covers steps 2 and 3 in one place. The order of the middleware matters: routes first, then the 404 catch-all, then the error handler, which Express recognises because it takes four arguments.
const express = require('express');
const app = express();
// Express 4 only: pass async errors on to the error handler
const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/products/:slug', wrap(async (req, res) => {
const product = await db.products.findBySlug(req.params.slug);
if (!product) return res.status(404).render('not-found');
res.render('product', { product });
}));
// Anything no route matched
app.use((req, res) => res.status(404).render('not-found'));
// Every error ends up here
app.use((err, req, res, next) => {
console.error(req.method, req.originalUrl, err);
if (res.headersSent) return next(err);
res.status(err.status || 500).render('error');
});
app.listen(3000, '127.0.0.1');
The headersSent check matters when a response was already partly sent, for example while
streaming a file. Passing the error on lets Express close the connection rather than trying to send a
second set of headers, which would throw another error.
err.message or the stack trace to visitors in production.
Messages from database drivers and libraries can include table names, file paths or connection details.
Log the detail and show a plain message.Next.js status codes in practice
Next.js gets most status codes right when you use its own functions, and wrong when you imitate them with ordinary components.
| Situation | Do this | Status sent |
|---|---|---|
| No route matches the URL | Provide app/not-found.tsx or pages/404.tsx | 404 |
| Route matches but the record is missing | Call notFound() (App Router) or
return { notFound: true } (Pages Router) | 404 |
| Rendering a "not found" component yourself | Avoid; replace with notFound() | 200, a soft 404 |
| Uncaught error while rendering on the server | Let it reach error.tsx; log it | 500 |
| Page moved | permanentRedirect(), or redirects in next.config.js
for fixed paths | 308 |
Route handler under app/api | Return Response.json(body, { status })
with the right code | Whatever you set |
With streaming, once Next.js has begun sending the page it cannot change the status. If notFound()
is reached inside a part of the page that renders later, inside a Suspense boundary, the response has
already gone out as 200; Next.js adds a noindex robots tag in that case, but the code stays
wrong. Load the data that decides whether the page exists at the top of the page component, before anything
is streamed, so the check runs first.
Static exports built with output: 'export' have no server of their own. The host decides
the status, so configure it to serve your generated 404.html with a 404 code for unknown
paths rather than serving index.html with 200.
try/catch,
and limit it with a matcher so it does not run on static files and images.Reading PM2 when the site shows 502
When a self-hosted Node site suddenly returns 502 on every page, the proxy in front is almost always fine and the Node process is down or restarting. PM2 tells you which within a minute.
pm2 list # status, restart count and memory per process
pm2 describe app-name # script path, working folder, log file locations
pm2 logs app-name --lines 200 --err # the last errors before each restart
- Status "errored" or "stopped": PM2 gave up. The error log shows why the process could not start: a missing build, a missing environment variable, or a port already in use.
- Status "online" but the restart count keeps rising: the app crashes and PM2 brings it back. Visitors get 502s during each gap. Find the crash in the error log.
- Memory creeping up until a restart: a leak, or a cache held in memory with no size
limit.
max_memory_restartbuys time while you find it. - Online and stable, but still 502: the app is listening on a different port or address from the one the proxy uses. The 502 Bad Gateway guide covers matching the two, and the idle connection timing that causes occasional 502s behind load balancers.
A minimal ecosystem.config.js keeps these settings in the repository rather than in someone's shell history:
module.exports = {
apps: [{
name: 'shop-web',
cwd: '/srv/shop/current',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000 -H 127.0.0.1',
instances: 2,
exec_mode: 'cluster',
max_memory_restart: '600M',
env: { NODE_ENV: 'production' }
}]
};
Hosted platforms and their limits
On managed platforms you do not run PM2 or nginx, but the same failures appear with different names. The platform's function or runtime logs are the equivalent of the server error log, and each platform documents its own error codes.
- Run-time limits. Functions that take too long are stopped and the platform returns a timeout error, typically a 504. Limits differ by platform, plan and runtime and have changed over time, so check the current documentation rather than relying on a number you remember.
- Memory limits. Functions that exceed their memory are killed, often logged as an out-of-memory or runtime exit error, and surface as 500 or 502.
- Edge runtime restrictions. Code running at the edge, including Next.js middleware on many hosts, cannot use every Node API. A library that needs the file system or native modules can build fine and then fail on the first request.
- Environment variables. A variable set for preview deployments but not production is a common cause of a 500 that appears only on the live site.
- Cold starts. The first request after a quiet spell can be slow while the function starts. That usually shows as a slow response rather than an error; the timeout errors guide covers what to do when it crosses into a failure.
Preventing repeat failures
- Add automated tests that request a missing path and a missing record and assert a 404, and trigger an error and assert a 500.
- Send errors to a logging or error-tracking service so you see crashes before visitors report them.
- Expose a
/healthzroute that checks the database connection, and point your uptime monitor and load balancer at it. - Treat a rising PM2 restart count as a bug report, not a sign that restarts are working.
- Keep deploys atomic: build, test, switch, reload, with the previous release kept for rollback.
- Run the HTTP Error Audit after each release, as many status code mistakes only show on routes nobody clicks in testing.
1. Why does my Next.js 404 page return a 200 status?
Usually because the page renders a "not found" component itself instead of calling notFound(), or because the check that decides whether the page exists happens inside a part of the page that streams later, after the 200 has already been sent. Call notFound() from next/navigation as soon as the data lookup fails, near the top of the page component. In the Pages Router, return notFound set to true from getServerSideProps or getStaticProps. Test the result with curl, not the browser.
2. Why does one error take my whole Node site down?
A Node app is one long-running process serving every request. If an exception or promise rejection is not handled anywhere, Node's default behaviour is to log it and exit, and nothing is left listening until the process is started again. The proxy in front then returns 502 for every page. Handle errors in each route, add an error-handling middleware, and run the app under a process manager such as PM2 or systemd so it restarts automatically after any crash.
3. Should I catch uncaughtException and keep the app running?
No. After an uncaught exception the process may be in an unknown state, with half-finished work, open connections or corrupted data in memory. Node's own documentation advises against resuming normal operation after one. Use the handler to log the error with as much detail as possible and then exit, and let PM2, systemd or your platform start a fresh process. Fix the underlying bug by handling that error where it happens, so the handler becomes a rare safety net.
4. How do I stop 502 errors after my server reboots?
The Node app was running in a terminal session or was started by hand and did not come back after the reboot. If you use PM2, run pm2 startup, execute the command it prints to register PM2 as a system service, start your apps, then run pm2 save so the process list is restored at boot. Alternatively, write a systemd unit for the app with a restart policy. Reboot a test server once to confirm the site returns by itself.
5. Why do I get a 500 error only on the live site and not locally?
The most common reasons are a missing or different environment variable in production, a database or API that the live server cannot reach, code paths that only run with production settings, and features that behave differently in the production build, such as caching and static generation. Read the platform or server logs for the failing request; the error message usually names the missing value or failed connection. Compare the live environment variables against your local ones.
6. Does Express 5 change how errors are handled?
Yes, in one important way. In Express 5, if an async route handler or middleware throws or returns a rejected promise, the error is passed to your error-handling middleware automatically. In Express 4 you had to catch it yourself and call next with the error, or use a wrapper function. The error-handling middleware itself still works the same way, with four arguments, placed after all routes. Check the migration notes before upgrading, as other behaviour changed too.
7. Are Vercel function timeouts reported as 504 errors?
Typically yes. When a serverless function runs longer than the allowed duration, the platform stops it and returns a gateway timeout, and the function logs show a timeout message for that request. The allowed duration depends on your plan and settings and has changed over time, so check the current documentation. You can raise it for a specific route within your plan's limits, but caching the result or moving slow work to a background job is usually the better fix.
The HTTP Error Audit requests every URL in your crawl scope, records the status code and response time, follows redirects and flags anything outside the 200 range, so crashes and wrong codes show up before search engines find them.
Open HTTP Error Fixes โ