Learn which HTTP status codes to send and when, and how to read and set response headers in Express 5 with res.status, res.set, res.append and res.type.

Bishal Biswas
Self Learner
What you will learn
- What the five families of HTTP status codes mean
- The dozen or so status codes you will use almost every day
- How to set a status code safely in Express 5
- How to set, add, read and remove response headers
- Which headers Express adds for you, and how to inspect them
Why status codes matter
Imagine you post a letter and get a reply. Before reading the letter, you glance at a coloured sticker on the envelope: green means “all good”, yellow means “go somewhere else”, red means “you made a mistake”, black means “we had a problem”. That sticker is the status code.
A status code is a three-digit number at the very start of every HTTP response. Programs read it before anything else. A frontend app checks it to decide whether to show data or an error. Search engines use it to decide whether a page still exists. Monitoring tools count 500s to know when your server is in trouble.
Sending the right code is part of being a good API. Sending 200 OK with { "error": "not found" } in the body confuses every tool that talks to you.
The five families
The first digit tells you the family:
| Range | Family | Meaning in plain words |
|---|---|---|
1xx |
Informational | “Hold on, still working.” Rare in Express apps. |
2xx |
Success | “It worked.” |
3xx |
Redirection | “Look somewhere else.” |
4xx |
Client error | “You (the client) sent something wrong.” |
5xx |
Server error | “We (the server) broke.” |
A good rule: 4xx means the client should change the request; 5xx means the client can try again later but the fix is on your side.
The codes you will actually use
You do not need to memorise all of them. These cover most real apps.
Success (2xx)
| Code | Name | Use it when |
|---|---|---|
200 |
OK | A normal successful response. The default in Express. |
201 |
Created | A POST created something new. |
204 |
No Content | Success with nothing to send back, often after DELETE. |
Redirection (3xx)
| Code | Name | Use it when |
|---|---|---|
301 |
Moved Permanently | A URL moved forever. |
302 |
Found | A temporary redirect. The res.redirect() default. |
304 |
Not Modified | The client’s cached copy is still fresh. Express sends this for you. |
You met these in Sending JSON, Files and Redirects.
Client errors (4xx)
| Code | Name | Use it when |
|---|---|---|
400 |
Bad Request | The request is broken or invalid, like malformed JSON or a missing field. |
401 |
Unauthorized | The client is not logged in, or the login token is invalid. |
403 |
Forbidden | The client is logged in, but not allowed to do this. |
404 |
Not Found | The resource does not exist. |
405 |
Method Not Allowed | The URL exists, but not for this method. |
409 |
Conflict | The request clashes with current data, like an email already taken. |
413 |
Content Too Large | The request body is too big. |
415 |
Unsupported Media Type | The body is in a format you do not accept. |
422 |
Unprocessable Content | The data is well formed but fails your rules. Some APIs use 400 for this instead. |
429 |
Too Many Requests | The client is sending requests too fast. |
Note:
401vs403trips up everyone. Think of a club.401is “I don’t know who you are, show me your ID”.403is “I know who you are, and you are not on the list”.
Server errors (5xx)
| Code | Name | Use it when |
|---|---|---|
500 |
Internal Server Error | Something unexpected went wrong in your code. |
502 |
Bad Gateway | A server your app depends on sent a bad answer. |
503 |
Service Unavailable | The server is overloaded or down for maintenance. |
Status codes Express sends for you
You do not always have to set the code yourself:
- A normal
res.send()orres.json()uses 200. - When no route matches, Express sends 404 with
Cannot GET /path. - When your code throws an error, Express’s built-in error handler sends 500.
res.redirect()sends 302.- When a browser’s cached copy is still fresh, Express answers 304 with no body (more on this below).
Setting the status code
You already know res.status(). Here it is in a small “users” API that uses several codes correctly.
// app.js
import express from 'express';
const app = express();
app.use(express.json()); // read JSON bodies (explained in a later lesson)
const users = [{ id: 1, email: 'asha@example.com' }];
app.get('/api/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user); // 200
});
app.post('/api/users', (req, res) => {
const email = req.body?.email;
if (!email) {
return res.status(400).json({ error: 'email is required' });
}
if (users.some((u) => u.email === email)) {
return res.status(409).json({ error: 'email already registered' });
}
const user = { id: users.length + 1, email };
users.push(user);
res.status(201).json(user);
});
app.delete('/api/users/:id', (req, res) => {
const index = users.findIndex((u) => u.id === Number(req.params.id));
if (index === -1) {
return res.sendStatus(404);
}
users.splice(index, 1);
res.sendStatus(204);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Try it and watch the first line of each response:
curl -i -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" -d '{"email":"ben@example.com"}'
# HTTP/1.1 201 Created
curl -i -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" -d '{"email":"ben@example.com"}'
# HTTP/1.1 409 Conflict
curl -i -X DELETE http://localhost:3000/api/users/1
# HTTP/1.1 204 No Content
Express 5 is strict about status codes
In Express 5, res.status() only accepts integers from 100 to 999. Anything else throws an error right away:
res.status('404'); // throws TypeError: Invalid status code (not an integer)
res.status(200.5); // throws TypeError
res.status(1000); // throws RangeError (outside 100-999)
This is good: in Express 4 these mistakes could slip through silently. But watch out when a code comes from a variable, for example from an error object or a config file. Make sure it is a real number.
const code = Number(someValue);
const isValid = Number.isInteger(code) && code >= 100 && code <= 999;
res.status(isValid ? code : 500).json({ error: 'Something failed' });
Tip: Node has a built-in list of status names.
http.STATUS_CODES[418]returns"I'm a Teapot". Import it withimport http from 'node:http';. Handy for logging.
What are response headers?
Response headers are Name: value lines sent before the body. They describe the response: what type of data it is, how long it is, whether it may be cached, and more. The browser reads them but does not show them on the page.
Use curl -i to see them:
curl -i http://localhost:3000/api/users/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 35
ETag: W/"23-..."
Date: Sun, 25 Oct 2026 09:14:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"email":"asha@example.com"}
You can also see headers in the browser: open the developer tools (F12), go to the Network tab, click a request, and look at Response Headers.
Headers Express and Node add for you
| Header | Added by | What it means |
|---|---|---|
Content-Type |
Express (res.send, res.json) |
The format of the body |
Content-Length |
Express | Size of the body in bytes |
ETag |
Express | A fingerprint of the body, for caching |
X-Powered-By |
Express | Says “Express”. You may want to remove it. |
Date |
Node | When the response was created |
Connection, Keep-Alive |
Node | Lets the client reuse the connection |
Setting headers
res.set(): set one or many
res.set(name, value) sets a header. If the header already exists, it is replaced.
app.get('/api/report', (req, res) => {
res.set('Cache-Control', 'no-store'); // never cache this
res.set('X-Report-Version', '3'); // your own custom header
res.json({ total: 42 });
});
You can set several at once with an object:
res.set({
'Cache-Control': 'public, max-age=3600', // cache for one hour
'Content-Language': 'en',
});
Header names are case-insensitive, so cache-control and Cache-Control are the same header.
res.append(): add without replacing
Some headers can appear more than once. res.append() adds a value instead of overwriting.
res.append('Link', '</api/users?page=2>; rel="next"');
res.append('Link', '</api/users?page=9>; rel="last"');
// Link: </api/users?page=2>; rel="next", </api/users?page=9>; rel="last"
Set-Cookie is the most common header that appears many times. Express gives you res.cookie() for that, which you will use in Cookies with cookie-parser.
res.type(): the content type shortcut
res.type() sets Content-Type from a short name or file extension.
res.type('json'); // application/json; charset=utf-8
res.type('html'); // text/html; charset=utf-8
res.type('.png'); // image/png
res.type('text'); // text/plain; charset=utf-8
This is useful when res.send() would guess wrong. For example, sending a string of plain text would otherwise be labelled as HTML:
app.get('/robots.txt', (req, res) => {
res.type('text').send('User-agent: *\nDisallow: /admin');
});
Reading and removing headers
res.set('X-Trace', 'abc');
res.get('X-Trace'); // 'abc'
res.removeHeader('X-Trace'); // gone (this one comes from Node)
Removing X-Powered-By
The X-Powered-By: Express header tells everyone which framework you use. It does no harm by itself, but there is no reason to advertise it. Turn it off with an Express setting:
const app = express();
app.disable('x-powered-by');
Later, Security Headers with Helmet shows how one package removes this header and adds many protective ones.
Timing: headers go first
HTTP sends the status line and headers before the body. Once Express starts sending the body, the headers are locked. Any change after that throws ERR_HTTP_HEADERS_SENT.
Your code What goes over the wire
--------- -----------------------
res.status(201) ---+
res.set('X-Id', '7') ---+---> HTTP/1.1 201 Created
X-Id: 7
Content-Type: application/json
res.json({ id: 7 }) ------> {"id":7} (response finished)
res.set('X-Late', '1') ------> ERROR: headers already sent
So always: status, then headers, then body.
ETag and 304: free caching
Remember the ETag header? It is a fingerprint of the body. When a browser asks for the same URL again, it sends that fingerprint back in an If-None-Match header. If the body would be identical, Express skips sending it and answers 304 Not Modified with an empty body. The browser then uses its cached copy.
1st request: GET /api/users/1
<- 200 OK, ETag: W/"23-abc", body: {...}
2nd request: GET /api/users/1, If-None-Match: W/"23-abc"
<- 304 Not Modified, no body (use your copy)
This saves bandwidth with zero work from you. If you see 304 in the Network tab, nothing is broken. It is caching doing its job. You will learn to control caching yourself in Caching with Redis and HTTP Cache Headers.
Common mistakes
Always sending 200. Returning res.json({ error: 'Not found' }) sends 200 OK. The client thinks it worked. Add res.status(404).
Mixing up 401 and 403. Not logged in: 401. Logged in but not allowed: 403.
Using 500 for bad input. If the client sent bad data, that is a 4xx. Save 5xx for your own failures.
Passing a string to res.status(). Express 5 throws on res.status('404'). Use the number 404.
Setting headers after the body. Set the status and headers first, then call res.send() or res.json() last.
Key takeaways
- Status codes come in five families: 1xx info, 2xx success, 3xx redirect, 4xx client error, 5xx server error.
- Learn the everyday ones:
200,201,204,301,302,400,401,403,404,409,500. - In Express 5,
res.status()throws unless you pass an integer from 100 to 999. - Use
res.set()to set or replace headers,res.append()to add, andres.type()forContent-Type. - Headers must be set before the body is sent.
- Express adds
ETagfor you and answers304when the client’s cache is still fresh.
Practice
- Run
curl -iagainst three different routes in your app and write down every header you see. Which ones did your code set? - Add
app.disable('x-powered-by')and confirm withcurl -ithat the header is gone. - Create
GET /api/teapotthat answers with status418and a JSON body. Then tryres.status('418')and read the error. - Request the same JSON route twice in the browser with the Network tab open. Do you see a
304on the second request? Find theETagandIf-None-Matchheaders.
What’s next
So far you have sent files one route at a time. Next, learn to serve a whole folder of images, CSS and JavaScript in Serving Static Files with express.static.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready