Node.js / Basic: the Node.js runtime / Lesson 5
HTTP requests and responses
Use core HTTP to identify requests, send complete responses, and test a server without leaving it running.
On this page
Lesson goals
- Explain the concepts and trade-offs behind http requests and responses.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
HTTP is an application protocol
An HTTP request carries a method, a target, headers, and sometimes a body. The response carries a status, headers, and an optional body. A TCP connection can carry more than one request, so connection lifetime and request lifetime differ. GET usually retrieves a representation, while POST asks the server to process submitted content. Your application must define each route’s behavior rather than treating a method name as authorization. HTTPS adds transport encryption and peer authentication; a local plain-HTTP example is appropriate for learning but does not replace a deployment’s TLS design.
Parse targets and choose routes
Core HTTP exposes req.url as a request target rather than a fully trusted public URL. Construct a URL with a fixed base for parsing local path and query components. Do not build security-sensitive absolute URLs from an untrusted Host header. Match method and pathname together; a query string should not accidentally become part of the route name. Decide whether an unmatched request means 404 or whether a known path with a different method should return 405. Keep routing decisions small enough to review before adding framework abstractions.
Complete exactly one response
The first server returns JSON only for GET /health and returns a plain 404 otherwise. writeHead selects the status and headers; end completes the response. Return immediately after finishing a branch so execution cannot fall through into a second response. Once headers have been sent, a later exception cannot safely replace the response with a fresh JSON error. A production handler needs a clear error boundary and a policy for partial responses. Here each request is small and the client consumes its body before the server closes.
import { createServer } from 'node:http';
import { once } from 'node:events';
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://local');
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
res.writeHead(404); res.end('Not found');
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
try {
const response = await fetch(`http://127.0.0.1:${server.address().port}/health`);
console.log(response.status, (await response.json()).ok);
} finally { await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); }
200 true
The client parses JSON only after receiving the response. finally closes the listener even when the client assertion fails.
Representations and HEAD
Content-Type describes how clients should interpret a body; JSON text should be labeled application/json. HTTP HEAD returns the headers of the selected representation without its body. The second example uses Buffer.byteLength because a text’s byte count can differ from its JavaScript character count. The content length describes the representation that a GET would send, while HEAD transmits zero body bytes. Avoid manually setting length for a body that a compression layer or streaming transform will change. Let the appropriate transport layer manage framing when the final size is unknown.
import { createServer } from 'node:http';
import { once } from 'node:events';
const body = 'hello';
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain', 'content-length': Buffer.byteLength(body) });
res.end(req.method === 'HEAD' ? undefined : body);
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
try {
const response = await fetch(`http://127.0.0.1:${server.address().port}`, { method: 'HEAD' });
console.log(response.status, response.headers.get('content-length'), (await response.text()).length);
} finally { await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); }
200 5 0
HEAD advertises the five-byte representation but sends no body. The observed text length is therefore zero.
Own the server lifecycle
Both examples listen on 127.0.0.1 with port zero, which asks the operating system for an available ephemeral port. They obtain the actual port only after listening succeeds and close the server in finally. This avoids occupying a fixed port and prevents a teaching example from hanging after its assertion. Server error events still need handling in long-running applications. Set meaningful request and header timeouts, constrain bodies, and consider a reverse proxy in deployment. Learning the core request lifecycle makes those later operational decisions easier to reason about, even when a framework supplies the routing.
Common errors and fixes
ERR_HTTP_HEADERS_SENT
- Why it happens
- A branch tried to set headers after another branch responded.
- How to fix it
- Return after end and centralize response ownership.
- Prevent it next time
- Test every handler branch including failures.
A request never completes
- Why it happens
- The handler wrote headers but did not end its response.
- How to fix it
- Call end or finish an owned stream on every completed path.
- Prevent it next time
- Give integration tests deadlines and verify resource cleanup.
EADDRINUSE
- Why it happens
- A fixed port is already occupied.
- How to fix it
- Use port zero in tests or configure a free application port.
- Prevent it next time
- Close test servers in finally.
Check your understanding
1. A GET request targets /health?verbose=1. Which value should routing inspect?
Show worked solution
Parse the target with a fixed base, inspect url.pathname as /health, and inspect searchParams separately for verbose. Match GET as well. Comparing the raw target to /health would incorrectly reject a valid query-bearing request.
2. A handler sends a 400 response and then continues into its success branch. Repair its control flow.
Show worked solution
Return immediately from the error branch after res.end. The success branch must be reachable only when validation passed. Test an invalid request and assert one status and one body; checking only the valid path would miss the duplicate-response defect.
Key takeaways
- An HTTP request carries a method, a target, headers, and sometimes a body.
- Core HTTP exposes req.url as a request target rather than a fully trusted public URL.
- Both examples listen on 127.0.0.1 with port zero, which asks the operating system for an available ephemeral port.