What does Express do when nothing matches?
Express tries your routes from the top of the file to the bottom. When none of them matches the method and path, it sends its own small page that says Cannot GET /opening-times, with status 404 Not Found. Press **GET /opening-times** and read it in the Requests view: the route line says no route matched, and the body is a bare <pre> tag.
That page is correct and also unhelpful. It has no link back, no styling and no hint of which site the visitor is on. Someone who followed an old link from a bird club newsletter or mistyped an address is stuck. A custom 404 page keeps them on your site: the same look as the other pages, a short sentence and a link home.
How do you add a custom 404 page?
Add an app.use() with a handler at the very end of your routes, just above app.listen(). app.use() without a path runs for every request that reaches it, whatever the method and address. Because Express only reaches it after every route above has had a turn and none of them answered, it runs exactly when nothing else matched.
Always set the status with res.status(404). Without it the page goes out as 200 OK, which tells browsers, search engines and uptime monitors that the page exists. Search engines then keep the broken address in their results, and nothing in your logs counts how many visitors hit missing pages. A page that only looks like an error is called a soft 404, and search engines report it as a problem.
Order is the whole trick. Put the same app.use() at the top of the file and every request gets the 404 page, including the home page, because the catch-all answers before the routes are tried. If a page you know exists suddenly says not found, look at where your catch-all sits.
// ...all your app.get() routes above...
app.use((req, res) => {
res.status(404).send(
'<h1>Page not found</h1><p><a href="/">Back to the bakery</a></p>'
);
});
app.listen(3000);What happens when a route throws an error?
Press **GET /specials**. The route tries to read specials.txt, which does not exist, so fs.readFileSync() throws an error. Express catches it, prints it in the Log, and answers with status 500 Internal Server Error. The server keeps running: the next request works as normal.
Look at what the visitor sees. The default error page in the practice server includes the error message, file path and all. On your own computer Express goes further while NODE_ENV is not production: it prints the whole stack trace, with file names and line numbers from your project. That is handy while you build and harmful on a live site, because it describes your code and folders to anyone who can trigger the error.
In Express 5 the same thing happens for async route handlers. If an await inside the handler rejects, Express catches it and passes it on just like a thrown error. Older Express 4 code needed extra wrappers for this, and you will still see them in tutorials and AI answers.
How does error-handling middleware work?
An error handler is an app.use() function with four arguments: (err, req, res, next). Express counts the arguments to tell it apart from normal middleware. When a route throws, or calls next(err), Express skips every normal handler and jumps to the next function with four arguments. The first argument is the error.
Put the error handler last, after the 404 handler. Inside it, do two jobs: log the full error for yourself with console.error(err), and send the visitor a short page with res.status(500). Keep all four arguments even if you never call next, because with only three Express treats it as a normal handler and errors skip right past it.
Some errors carry a status of their own. When express.json() receives broken JSON, the error has err.status set to 400. A handler that answers res.status(err.status || 500) respects those, so a bad request from the visitor is not reported as a fault in your server.
app.use((err, req, res, next) => {
console.error(err); // full details, for you
const status = err.status || 500;
res.status(status).send(
'<h1>Something went wrong</h1><p>Please try again in a minute.</p>'
); // calm words, for visitors
});

When should you call next(err) yourself?
A thrown error reaches the error handler on its own. Callbacks are different: an error inside a callback, such as the older fs.readFile(file, (err, text) => ...) style, happens after your route function has returned, so Express cannot catch it. Pass it on yourself with next(err), and add return so the rest of the callback stops.
You can also create errors on purpose. When a bike shop booking needs a free slot and the data says there is none, a route could build const err = new Error('No free slot'); err.status = 409; and call next(err), letting the error handler decide how to show it. For ordinary missing items, though, answering directly with res.status(404) in the route is clearer, as the /menu/:id route does.
app.get('/specials', (req, res, next) => {
fs.readFile(path.join(__dirname, 'specials.txt'), 'utf8', (err, text) => {
if (err) return next(err); // hand it to the error handler
res.send(`<h1>Specials</h1><p>${text}</p>`);
});
});What should visitors see, and what should you log?
Visitors need three things: a sign that something failed, a hint about what to do, and a way to continue. A short line such as “Something went wrong. Please try again in a minute.” plus a link home covers all three. They do not need the error name, file paths, line numbers, database messages or the values your code was working with.
The Log needs the opposite: everything. Log the error object itself, since console.error(err) prints the message and stack together, and add context such as req.method and req.path so you know which request failed. On a live site those lines are often the only record you get. Leave out passwords, cookies and anything a visitor typed into a private form.
How do you check your error pages?
Send three kinds of request and read the status of each in the Requests view: a real page should say 200, a made-up address 404, and the broken /specials route 500. Then open the bodies. The 404 and 500 pages should be your own, and neither should contain an error message or a path such as /app/specials.txt.
Open the Log after the /specials request. You should see the ENOENT error there, tagged with that request. If the Log is empty, your error handler is swallowing errors. If the visitor sees the error text, it is leaking them. Both happen often in generated code, so check both every time.
What do AI tools get wrong with error pages?
Three patterns come up again and again. The first is a catch-all placed too early, often added at the top of the file “for safety”. The second is an error handler that sends err.message or err.stack straight back to the browser, which looks helpful in development and leaks details in production. The third is a 404 page sent without res.status(404), which looks right in the browser and is wrong everywhere else.
Watch for error handlers with three arguments, too. They never receive errors, so failures fall through to Express's default page and the custom one is never used. Count the arguments: four, with err first.
Field notes
- When no route matches, Express sends a small Cannot GET page with status 404.
- An
app.use()placed after all routes catches every unknown address for your own 404. - An error handler has four arguments,
(err, req, res, next), and Express spots it by that. - Show visitors a short calm message, and keep the error details in the log.
Easy to get wrong
Express tries handlers in the order you add them. A catch-all at the top answers every request, so the routes below it never run.
Express recognises an error handler by its four arguments. With three it is treated as normal middleware and never receives errors.
A stack trace shows file paths, line numbers and sometimes data. Log it for yourself and give visitors a short, plain message.
Without a status the page is sent as 200 OK. Search engines index it as a real page and monitoring never counts it as missing.
Questions people ask
Can I show a different 404 page for the API and the website?
Yes. Add app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' })) before the general 404 handler. Requests under /api get JSON, and everything else gets the HTML page.
Why doesn't the server stop when a route throws?
Express catches errors thrown inside route handlers, including rejected await calls in Express 5, and turns them into a 500 response. Errors thrown later in a callback or timer are not caught, and on a real server they can crash the whole process. Use next(err) in callbacks.
Should a missing menu item use the 404 handler or answer in the route?
Answer in the route. /menu/9 matched a real route, and the route knows the item is missing, so res.status(404) there can say “We don't bake that” with a link to the menu. The catch-all is for addresses no route recognises.
Does a custom 404 page help with search engines?
They only look at the status code. A real 404 tells them to drop the address. The friendly page is for people, who get a way back to the rest of the site.