| |

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:

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that created a resource
204No ContentSuccessful DELETE, no body
301Moved PermanentlyResource moved, update bookmarks
302FoundTemporary redirect
304Not ModifiedConditional GET, client cache is valid
400Bad RequestMalformed request
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource does not exist
405Method Not AllowedMethod not supported for the resource
409ConflictState conflict, e.g., duplicate
422Unprocessable EntityValidation failed
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server error
502Bad GatewayUpstream returned invalid response
503Service UnavailableServer 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.

HeaderPurpose
Cache-ControlDirectives for caching (no-store, no-cache, max-age=3600)
ETagOpaque identifier for a specific version
Last-ModifiedTimestamp of the last modification
ExpiresAbsolute expiration time (HTTP/1.0)
VaryHeaders 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

PropertyValue
req.methodHTTP method
req.urlPath and query string
req.headersLowercased headers
req.httpVersionHTTP version
req.socketUnderlying socket

Response Methods

MethodPurpose
res.writeHead(status, headers)Set status and headers
res.setHeader(name, value)Set a single header
res.getHeader(name)Read a header
res.statusCodeStatus code
res.write(chunk)Write a chunk
res.end([chunk])Finish the response

Status Codes

CodeMeaning
200OK
201Created
204No Content
301Moved Permanently
302Found
304Not Modified
400Bad Request
401Unauthorized
403Forbidden
404Not Found
405Method Not Allowed
409Conflict
422Unprocessable Entity
429Too Many Requests
500Internal Server Error
502Bad Gateway
503Service Unavailable

Common Headers

HeaderPurpose
Content-TypeBody media type
Content-LengthBody byte length
Cache-ControlCaching directives
ETagVersion identifier
Last-ModifiedModification timestamp
LocationRedirect target
AllowSupported methods
Retry-AfterRetry delay
WWW-AuthenticateAuth 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

PitfallWhy It HappensFix
Client does not parse JSONMissing Content-TypeSet application/json
Response never endsMissing Content-Length or end()Set length or use chunked
401 vs 403 confusionBoth mean denied401 = unauthenticated, 403 = unauthorized
Cache serves stale dataNo Cache-ControlAdd cache directives
Redirect loopWrong LocationCheck the URL
Client hangsBody not framedSet Content-Length or end()
Stack trace leakedError passed to bodySanitize 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

ItemValue
Request methodreq.method
Request URLreq.url
Request headersreq.headers
Parse URLnew URL(req.url, base)
Set status and headersres.writeHead(status, headers)
Set single headerres.setHeader(name, value)
Set statusres.statusCode
Body lengthContent-Length
Body typeContent-Type
CachingCache-Control, ETag
RedirectLocation
Auth challengeWWW-Authenticate

Key takeaways:

  • The status code is the first thing a client checks. 200 OK means success. 404 Not Found means the resource does not exist. 500 Internal Server Error means the server failed. Returning 200 with an error in the body is incorrect and forces the client to parse the body to discover the failure.
  • Content-Type tells the client how to interpret the body. application/json, text/html, text/plain, and application/octet-stream are the common types. Without the correct type, the client may display the data as text or fail to parse it.
  • Content-Length tells 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 Allowed requires the Allow header. The header lists the methods the server supports for the resource. Without it, the client does not know what it can do.
  • 401 Unauthorized requires the WWW-Authenticate header. The header describes the authentication scheme. 403 Forbidden is used when the client is authenticated but not authorized.
  • Caching headers control how clients and proxies store the response. Cache-Control: no-store prevents storage. ETag and If-None-Match enable conditional requests that return 304 Not Modified when 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!