Node.js 27 🟢 Handling HTTP Requests, Response Headers, and Status Codes
An HTTP exchange is a request and a response. The request has a method, a URL, headers, and an optional body. The response has a status code, headers, and an optional body. The node:http module exposes each of these directly. The previous chapter covered creating a server and streaming data. This chapter covers the details of the exchange: parsing the request, setting the response headers, and choosing the status code. These details are what make an HTTP server correct rather than merely functional.
Key point: The status code tells the client what happened. The headers tell the client how to interpret the response. The body carries the data. Setting the correct status code and the correct headers is not optional—a client that receives a 200 OK for a request that failed will behave differently from one that receives a 404 Not Found or a 500 Internal Server Error. The Content-Type header determines how the body is parsed. The Content-Length header determines when the body is complete.
Why request and response details matter
The correctness problem. An HTTP server that returns 200 OK for every request is not correct. A client that receives 200 with an error message in the body must parse the body to discover the failure. A client that receives 404 or 500 knows from the status line that the request failed. The status code is the first thing a client checks, and it should reflect what actually happened.
The parsing problem. The request arrives as a stream of bytes. The method, URL, headers, and body must be parsed from those bytes. The node:http module does the parsing and exposes the result through the IncomingMessage object. The headers are lowercased and deduplicated. The URL is the path and query string. The body is a separate stream.
The framing problem. HTTP/1.1 needs to know where the response body ends. If Content-Length is set, the body is that many bytes. If it is not set and the response uses chunked transfer encoding, the body ends with a zero-length chunk. If neither is set, the connection is closed to signal the end. Getting this wrong causes the client to hang or to misparse the response.
The content-type problem. The Content-Type header tells the client how to interpret the body. application/json means the body is JSON. text/html means the body is HTML. application/octet-stream means the body is binary. Without the correct Content-Type, the client may display the data as plain text or fail to parse it.
The caching problem. Headers like Cache-Control, ETag, and Last-Modified control how clients and proxies cache the response. A response that should not be cached must say so. A response that can be cached should provide the information the client needs to revalidate it.
a. Parsing the request
The IncomingMessage object has the properties the handler needs to understand the request.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
console.log(req.method); // 'GET', 'POST', 'PUT', etc.
console.log(req.url); // '/path?query=value'
console.log(req.httpVersion); // '1.1'
console.log(req.headers); // lowercased header object
console.log(req.headers.host); // 'localhost:3000'
});
The req.url is the path and query string, not the full URL. The protocol and host are in the headers and the socket. To parse the URL and query string, use the WHATWG URL class.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
console.log(url.pathname); // '/users'
console.log(url.searchParams.get('id')); // '42'
});
The base URL is constructed from the Host header. The pathname is the path without the query string. The searchParams is a URLSearchParams object with the query parameters.
The HTTP method determines how the request is handled. A GET request has no body. A POST or PUT request has a body. A DELETE request may or may not have a body.
const server = createServer((req, res) => {
switch (req.method) {
case 'GET':
handleGet(req, res);
break;
case 'POST':
handlePost(req, res);
break;
case 'PUT':
handlePut(req, res);
break;
case 'DELETE':
handleDelete(req, res);
break;
default:
res.writeHead(405, { 'Allow': 'GET, POST, PUT, DELETE' });
res.end();
}
});
The 405 Method Not Allowed status is returned for methods the server does not support. The Allow header lists the methods it does support.
The req.headers object contains the request headers, lowercased. The host, user-agent, accept, content-type, and content-length headers are the ones handlers inspect most often. The authorization header carries credentials. The cookie header carries cookies.
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' });
res.end();
return;
}
const token = auth.slice(7);
The Authorization header is checked for the Bearer scheme. If it is missing or malformed, a 401 Unauthorized is returned with the WWW-Authenticate header describing the expected scheme.
b. Setting response headers and status codes
The res.writeHead() method sets the status code and the headers. It must be called before the body is written.
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ status: 'ok' }));
The first argument is the status code. The second is an object of headers. The headers are sent with the response. The status code is sent in the status line.
Individual headers can be set with res.setHeader() before writeHead is called.
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Custom', 'value');
res.statusCode = 200;
res.end('<h1>Hello</h1>');
The res.statusCode property sets the status code directly. The res.setHeader() method sets a header. When res.end() is called, the headers and status are sent.
Common status codes and their meanings:
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH |
201 | Created | Successful POST that created a resource |
204 | No Content | Successful DELETE, no body |
301 | Moved Permanently | Resource moved, update bookmarks |
302 | Found | Temporary redirect |
304 | Not Modified | Conditional GET, client cache is valid |
400 | Bad Request | Malformed request |
401 | Unauthorized | Missing or invalid authentication |
403 | Forbidden | Authenticated but not authorized |
404 | Not Found | Resource does not exist |
405 | Method Not Allowed | Method not supported for the resource |
409 | Conflict | State conflict, e.g., duplicate |
422 | Unprocessable Entity | Validation failed |
429 | Too Many Requests | Rate limit exceeded |
500 | Internal Server Error | Unexpected server error |
502 | Bad Gateway | Upstream returned invalid response |
503 | Service Unavailable | Server overloaded or in maintenance |
The Location header is used with 201 and 3xx responses.
res.writeHead(201, {
'Location': `/users/${userId}`,
'Content-Type': 'application/json',
});
res.end(JSON.stringify({ id: userId }));
The Content-Type header describes the body. The common types are application/json, text/html, text/plain, application/xml, application/octet-stream, multipart/form-data, and image/png.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>Hello</h1>');
The charset parameter specifies the character encoding. For text types, utf-8 is the common choice.
c. Body framing, caching, and error responses
The Content-Length header tells the client how many bytes the body contains. If it is set, the client reads that many bytes and knows the response is complete.
const body = JSON.stringify(data);
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
The Buffer.byteLength() function returns the byte length of the string, which may differ from the character length for multi-byte characters.
If Content-Length is not set, the response uses chunked transfer encoding. The client reads chunks until it receives a zero-length chunk. This is the default when res.write() is called multiple times without Content-Length.
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('chunk 1\n');
res.write('chunk 2\n');
res.end();
The Transfer-Encoding: chunked header is added automatically by the server.
Caching headers control how clients and proxies cache the response.
| Header | Purpose |
|---|---|
Cache-Control | Directives for caching (no-store, no-cache, max-age=3600) |
ETag | Opaque identifier for a specific version |
Last-Modified | Timestamp of the last modification |
Expires | Absolute expiration time (HTTP/1.0) |
Vary | Headers that affect the response |
The Cache-Control: no-store directive tells the client not to store the response. This is used for sensitive data. The Cache-Control: max-age=3600 directive tells the client the response is valid for one hour.
The ETag and Last-Modified headers enable conditional requests. The client sends If-None-Match or If-Modified-Since, and the server returns 304 Not Modified if the resource has not changed.
const etag = `"${hash(body)}"`;
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
res.end();
return;
}
res.writeHead(200, { 'ETag': etag, 'Content-Type': 'application/json' });
res.end(body);
Error responses should set the status code and provide a body that explains the error.
function sendError(res, status, message) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
sendError(res, 404, 'User not found');
sendError(res, 500, 'Internal server error');
The error response should not leak internal details. A 500 response should not include a stack trace. A 404 response should not reveal whether the resource exists but is forbidden.
A 405 response should include the Allow header.
res.writeHead(405, {
'Allow': 'GET, POST',
'Content-Type': 'text/plain',
});
res.end('Method Not Allowed\n');
A 429 response should include the Retry-After header.
res.writeHead(429, {
'Retry-After': '60',
'Content-Type': 'application/json',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
The Retry-After header tells the client how many seconds to wait before retrying.
Complete Example Session
// ============================================
// PART 1: REQUEST PROPERTIES
// ============================================
import { createServer } from 'node:http';
const server = createServer((req, res) => {
console.log(req.method);
console.log(req.url);
console.log(req.headers.host);
});
// ============================================
// PART 2: PARSING THE URL
// ============================================
const server = createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const id = url.searchParams.get('id');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ path: url.pathname, id }));
});
// ============================================
// PART 3: METHOD DISPATCH
// ============================================
const server = createServer((req, res) => {
switch (req.method) {
case 'GET': return handleGet(req, res);
case 'POST': return handlePost(req, res);
default:
res.writeHead(405, { 'Allow': 'GET, POST' });
res.end();
}
});
// ============================================
// PART 4: SETTING HEADERS
// ============================================
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ ok: true }));
// ============================================
// PART 5: SETTING STATUS CODE
// ============================================
res.statusCode = 201;
res.setHeader('Location', '/users/42');
res.end();
// ============================================
// PART 6: CONTENT-LENGTH
// ============================================
const body = JSON.stringify(data);
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
// ============================================
// PART 7: AUTHENTICATION CHECK
// ============================================
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' });
res.end();
return;
}
// ============================================
// PART 8: ERROR RESPONSES
// ============================================
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
// ============================================
// PART 9: CACHING WITH ETAG
// ============================================
const etag = `"${hash(body)}"`;
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
res.end();
return;
}
res.writeHead(200, { 'ETag': etag });
res.end(body);
// ============================================
// PART 10: RATE LIMITING
// ============================================
res.writeHead(429, {
'Retry-After': '60',
'Content-Type': 'application/json',
});
res.end(JSON.stringify({ error: 'Too many requests' }));
The ten parts covered request properties, URL parsing, method dispatch, setting headers, setting the status code, Content-Length, authentication, error responses, caching with ETag, and rate limiting.
Quick Reference
Request Properties
| Property | Value |
|---|---|
req.method | HTTP method |
req.url | Path and query string |
req.headers | Lowercased headers |
req.httpVersion | HTTP version |
req.socket | Underlying socket |
Response Methods
| Method | Purpose |
|---|---|
res.writeHead(status, headers) | Set status and headers |
res.setHeader(name, value) | Set a single header |
res.getHeader(name) | Read a header |
res.statusCode | Status code |
res.write(chunk) | Write a chunk |
res.end([chunk]) | Finish the response |
Status Codes
| Code | Meaning |
|---|---|
200 | OK |
201 | Created |
204 | No Content |
301 | Moved Permanently |
302 | Found |
304 | Not Modified |
400 | Bad Request |
401 | Unauthorized |
403 | Forbidden |
404 | Not Found |
405 | Method Not Allowed |
409 | Conflict |
422 | Unprocessable Entity |
429 | Too Many Requests |
500 | Internal Server Error |
502 | Bad Gateway |
503 | Service Unavailable |
Common Headers
| Header | Purpose |
|---|---|
Content-Type | Body media type |
Content-Length | Body byte length |
Cache-Control | Caching directives |
ETag | Version identifier |
Last-Modified | Modification timestamp |
Location | Redirect target |
Allow | Supported methods |
Retry-After | Retry delay |
WWW-Authenticate | Auth scheme |
Best Practices
✅ Do This:
// Set the status code that matches the outcome
res.writeHead(201, { 'Location': `/users/${id}` }); // ✅
// Set Content-Type for every response with a body
res.writeHead(200, { 'Content-Type': 'application/json' }); // ✅
// Set Content-Length when the body size is known
res.writeHead(200, { 'Content-Length': Buffer.byteLength(body) }); // ✅
// Use 404 for missing resources, not 200 with an error body
res.writeHead(404, { 'Content-Type': 'application/json' }); // ✅
// Return 405 with the Allow header for unsupported methods
res.writeHead(405, { 'Allow': 'GET, POST' }); // ✅
// Include WWW-Authenticate with 401
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' }); // ✅
// Use Cache-Control: no-store for sensitive data
res.writeHead(200, { 'Cache-Control': 'no-store' }); // ✅
❌ Don’t Do This:
// Don't return 200 for errors
res.writeHead(200);
res.end(JSON.stringify({ error: 'Not found' })); // ❌
// Don't forget Content-Type
res.writeHead(200);
res.end(JSON.stringify(data)); // client may not parse JSON // ❌
// Don't write the body before the headers
res.write('data');
res.writeHead(200); // error // ❌
// Don't use 401 for authorization failures
res.writeHead(401); // use 403 when authenticated but forbidden // ❌
// Don't leak stack traces in 500 responses
res.end(err.stack); // ❌
// Don't forget Content-Length for fixed-size bodies
res.writeHead(200); // uses chunked encoding // ⚠️
// Don't use 302 when 301 is correct
res.writeHead(302, { 'Location': '/new' }); // permanent move // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Client does not parse JSON | Missing Content-Type | Set application/json |
| Response never ends | Missing Content-Length or end() | Set length or use chunked |
| 401 vs 403 confusion | Both mean denied | 401 = unauthenticated, 403 = unauthorized |
| Cache serves stale data | No Cache-Control | Add cache directives |
| Redirect loop | Wrong Location | Check the URL |
| Client hangs | Body not framed | Set Content-Length or end() |
| Stack trace leaked | Error passed to body | Sanitize error responses |
Real-World Examples
1. JSON Response
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 42, name: 'Alice' }));
2. Created Response
res.writeHead(201, { 'Location': `/users/${user.id}` });
res.end(JSON.stringify(user));
3. No Content
res.writeHead(204);
res.end();
4. Redirect
res.writeHead(302, { 'Location': '/login' });
res.end();
5. Not Found
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
6. Method Not Allowed
res.writeHead(405, { 'Allow': 'GET, POST' });
res.end();
7. Unauthorized
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' });
res.end();
8. Rate Limited
res.writeHead(429, { 'Retry-After': '60' });
res.end();
9. Not Modified
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
res.end();
return;
}
10. Server Error
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
Visual
HTTP Request Structure
┌─────────────────────────────────────────────────────────────┐
│ POST /users?active=true HTTP/1.1 │
│ Host: example.com │
│ Content-Type: application/json │
│ Content-Length: 27 │
│ Authorization: Bearer abc123 │
│ │
│ {"name":"Alice","age":30} │
│ │
│ ──────────── ────────────────── ──────── │
│ method+path headers body │
│ │
└─────────────────────────────────────────────────────────────┘
HTTP Response Structure
┌─────────────────────────────────────────────────────────────┐
│ HTTP/1.1 201 Created │
│ Location: /users/42 │
│ Content-Type: application/json │
│ Content-Length: 25 │
│ │
│ {"id":42,"name":"Alice"} │
│ │
│ ────────────── ──────────────── ──────── │
│ status line headers body │
│ │
└─────────────────────────────────────────────────────────────┘
Status Code Families
┌─────────────────────────────────────────────────────────────┐
│ 1xx Informational │
│ 100 Continue │
│ │
│ 2xx Success │
│ 200 OK, 201 Created, 204 No Content │
│ │
│ 3xx Redirection │
│ 301 Moved Permanently, 302 Found, 304 Not Modified │
│ │
│ 4xx Client Error │
│ 400 Bad Request, 401 Unauthorized, 403 Forbidden, │
│ 404 Not Found, 405 Method Not Allowed, │
│ 409 Conflict, 422 Unprocessable Entity, │
│ 429 Too Many Requests │
│ │
│ 5xx Server Error │
│ 500 Internal Server Error, 502 Bad Gateway, │
│ 503 Service Unavailable │
│ │
└─────────────────────────────────────────────────────────────┘
Body Framing
┌─────────────────────────────────────────────────────────────┐
│ CONTENT-LENGTH SET │
│ │
│ Content-Length: 27 │
│ {"name":"Alice","age":30} │
│ ◀────────── 27 bytes ──────────▶ │
│ │
│ Client reads 27 bytes. Response complete. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ CHUNKED TRANSFER ENCODING │
│ │
│ Transfer-Encoding: chunked │
│ 1a\r\n{"name":"Alice","age":30}\r\n0\r\n\r\n │
│ │
│ Client reads chunks until zero-length chunk. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ CONNECTION CLOSE │
│ │
│ No Content-Length, no chunked. │
│ Connection closes to signal end. │
│ │
│ HTTP/1.1 discourages this. HTTP/1.0 uses it. │
│ │
└─────────────────────────────────────────────────────────────┘
Caching Flow
┌─────────────────────────────────────────────────────────────┐
│ FIRST REQUEST │
│ │
│ Client → GET /resource │
│ Server → 200 OK, ETag: "abc123", body │
│ │
│ Client stores the response and the ETag. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ SECOND REQUEST (CONDITIONAL) │
│ │
│ Client → GET /resource, If-None-Match: "abc123" │
│ │
│ Server checks the ETag: │
│ ├── Unchanged ──▶ 304 Not Modified, no body │
│ │ │
│ └── Changed ──▶ 200 OK, new ETag, new body │
│ │
│ 304 saves bandwidth: no body is transferred. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Request method | req.method |
| Request URL | req.url |
| Request headers | req.headers |
| Parse URL | new URL(req.url, base) |
| Set status and headers | res.writeHead(status, headers) |
| Set single header | res.setHeader(name, value) |
| Set status | res.statusCode |
| Body length | Content-Length |
| Body type | Content-Type |
| Caching | Cache-Control, ETag |
| Redirect | Location |
| Auth challenge | WWW-Authenticate |
Key takeaways:
- The status code is the first thing a client checks.
200 OKmeans success.404 Not Foundmeans the resource does not exist.500 Internal Server Errormeans the server failed. Returning200with an error in the body is incorrect and forces the client to parse the body to discover the failure. Content-Typetells the client how to interpret the body.application/json,text/html,text/plain, andapplication/octet-streamare the common types. Without the correct type, the client may display the data as text or fail to parse it.Content-Lengthtells the client when the body is complete. If it is set, the client reads that many bytes. If it is not set, the response uses chunked transfer encoding, and the client reads until the zero-length chunk.res.writeHead()must be called before the body is written. The status and headers are sent with the response. If the body is written first, the headers cannot be set.405 Method Not Allowedrequires theAllowheader. The header lists the methods the server supports for the resource. Without it, the client does not know what it can do.401 Unauthorizedrequires theWWW-Authenticateheader. The header describes the authentication scheme.403 Forbiddenis used when the client is authenticated but not authorized.- Caching headers control how clients and proxies store the response.
Cache-Control: no-storeprevents storage.ETagandIf-None-Matchenable conditional requests that return304 Not Modifiedwhen the resource has not changed.
Remember: An HTTP server is not just a function that returns data. It is a protocol endpoint that must speak HTTP correctly. The method, URL, headers, and body of the request must be parsed. The status code, headers, and body of the response must be set. The status code must match the outcome. The Content-Type must match the body. The Content-Length must match the frame. The caching headers must match the policy. Get these right, and the client behaves correctly. Get them wrong, and the client hangs, misparses, or displays the wrong thing. The details are the protocol, and the protocol is what makes the server useful.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!