Creating an HTTP Server
http.createServer
Section titled “http.createServer”http.createServer(callback) returns a Server instance. The callback — called the request listener — is invoked once per incoming HTTP request. It receives two arguments:
req— anIncomingMessageobject describing the request (method, URL, headers, body stream).res— aServerResponseobject used to write the response.
Call server.listen(port, callback) to start accepting connections. The callback fires once the server is ready.
Status codes and headers
Section titled “Status codes and headers”Every HTTP response has a status code and a set of headers. Use res.writeHead(statusCode, headersObject) to set both at once, before writing any body data.
Common status codes:
| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 400 | Bad Request |
| 404 | Not Found |
| 500 | Internal Server Error |
res.end — sending the response body
Section titled “res.end — sending the response body”res.end(data) writes the final chunk of the response body and signals to Node.js that the response is complete. Every request handler must call res.end() (or res.destroy()) — if you forget, the client will wait forever and the connection will time out.
You can also use res.write(data) to send data in chunks before calling res.end().
A complete server with status codes and headers
Section titled “A complete server with status codes and headers”Needs the Node.js runtime — open in StackBlitz to run.
Open in StackBlitz to see it run — the server requests itself once, prints the rendered HTML response with correct headers, then shuts down.
Responding with different status codes
Section titled “Responding with different status codes”Needs the Node.js runtime — open in StackBlitz to run.
Errors on req, res, and the server need a listener
Section titled “Errors on req, res, and the server need a listener”req, res, and the Server itself are all EventEmitters, and an 'error' event is special: if nothing is listening for it, Node.js throws it — which crashes the whole process, not just the one request. Calling res.end() a second time is a common way to trigger exactly this: the second call fails asynchronously with ERR_STREAM_WRITE_AFTER_END, and with no listener, that error has nowhere to go but up:
Output (verified on Node 24)
handling request - about to call res.end() twiceAttaching an 'error' listener to res turns that crash into an ordinary, handled event — the process survives, and you get to decide what happens next (log it, close the connection, alert on it):
Output (verified on Node 24)
res error handled instead of crashing: ERR_STREAM_WRITE_AFTER_END
client received: first responseAn error listener is a safety net, not a fix. The actual fix is to never call res.end() twice in the first place — an early return right after the first call guarantees that:
function handleRequest(req, res, hasError) { if (hasError) { res.writeHead(400); res.end('bad request'); return; // early return — nothing below this line runs for the error case } res.writeHead(200); res.end('ok');}
// Simulate a fake res object just to prove only one end() call happens per branchlet calls = 0;const fakeRes = { writeHead() {}, end() { calls++; } };handleRequest(null, fakeRes, true);handleRequest(null, fakeRes, false);console.log('res.end() was called', calls, 'times for 2 requests (never more than once each)');ERR_HTTP_HEADERS_SENT
Section titled “ERR_HTTP_HEADERS_SENT”Once headers are sent — by res.writeHead(), or by the first res.write()/res.end() — they cannot change. Trying anyway throws ERR_HTTP_HEADERS_SENT synchronously, which makes it easy to catch, unlike the asynchronous crash above:
Output (verified on Node 24)
caught: ERR_HTTP_HEADERS_SENT
client received: doneContent-Length vs. chunked transfer
Section titled “Content-Length vs. chunked transfer”When a handler calls res.end(data) exactly once, Node.js knows the full body size up front and sets Content-Length for you. When a handler calls res.write() more than once before res.end(), Node.js cannot know the total size in advance, so it switches to Transfer-Encoding: chunked instead — no Content-Length header at all:
Output (verified on Node 24)
/fixed Content-Length: 5 Transfer-Encoding: null
/chunked Content-Length: null Transfer-Encoding: chunkedContent-Length must be the exact number of bytes, not characters — for text that is pure ASCII those are the same number, but for anything else (Thai text, emoji) they are not. Buffer.byteLength(str) gives you the real byte count; the JavaScript string’s own .length counts UTF-16 code units instead:
const text = 'สวัสดี'; // Thai greeting - each character is multiple UTF-8 bytesconsole.log('.length (UTF-16 code units):', text.length);console.log('Buffer.byteLength (actual UTF-8 bytes):', Buffer.byteLength(text));If you ever set Content-Length by hand instead of letting res.end(data) compute it (for example, when streaming a body across multiple writes but still declaring the length upfront), use Buffer.byteLength, never .length — using .length on non-ASCII text declares fewer bytes than you actually send, and clients truncate the body at the declared length.
Keep-alive and the three timeouts
Section titled “Keep-alive and the three timeouts”By default, Node.js keeps a TCP connection open after a response so the client can send another request without a new handshake (Connection: keep-alive). Three independent timeouts, each with its own default, bound how long the server will wait:
Output (verified on Node 24)
headersTimeout: 60000
requestTimeout: 300000
keepAliveTimeout: 5000| Timeout | Default | Bounds |
|---|---|---|
headersTimeout | 60,000 ms | How long Node waits for the request headers to fully arrive |
requestTimeout | 300,000 ms | How long the entire request (headers + body) is allowed to take |
keepAliveTimeout | 5,000 ms | How long an idle keep-alive connection stays open waiting for the next request |
All three are plain properties you can reassign on the server object before it starts listening. When you need every open socket gone right now — during the tests in this module, or during a shutdown that has waited long enough — server.closeAllConnections() forcibly ends every connection, keep-alive or not, instead of waiting out these timeouts.
EADDRINUSE — the port is already taken
Section titled “EADDRINUSE — the port is already taken”Trying to listen() on a port that is already in use does not throw synchronously — it emits an 'error' event on the server, with a code you can check:
Output (verified on Node 24)
second listen failed: EADDRINUSEThis is the same rule as the top of this section: server.on('error', ...) is what stands between a busy port and an unhandled-exception crash on startup.
Graceful shutdown on SIGTERM
Section titled “Graceful shutdown on SIGTERM”A process manager (Docker, Kubernetes, systemd) sends SIGTERM when it wants your server to stop. Shutting down immediately drops any request currently in flight; a graceful shutdown stops accepting new connections but lets the in-flight ones finish first:
flowchart TD
A["Process receives SIGTERM"] --> B["server.close() - stop accepting new connections"]
B --> C{"Any in-flight requests?"}
C -->|yes| D["Wait for each in-flight response to finish"]
D --> C
C -->|no| E["close callback fires - process can exit"]
B -.->|timeout fallback| F["closeAllConnections() force-ends stragglers"]
Output (verified on Node 24)
SIGTERM received, draining in-flight requests: 1
in-flight requests remaining: 0
client got: slow response done
server closed - all in-flight requests finished firstserver.close() alone can hang forever if a client holds a keep-alive connection open with nothing in flight — production code pairs it with a timeout fallback that calls server.closeAllConnections() after a fixed grace period, so a shutdown that is taking too long still completes. The Production Server lesson at the end of this module wires that fallback in.
HTTP/2 in a few lines
Section titled “HTTP/2 in a few lines”Node.js also ships node:http2, which offers a compatibility API shaped almost exactly like node:http — createServer, a request listener, res.writeHead/res.end. This runs over plaintext HTTP/2 (h2c), which is what a same-process demo can speak without TLS certificates:
Output (verified on Node 24)
received over http/2: hello over h2cReal-world HTTP/2 servers almost always run over TLS (http2.createSecureServer) since every major browser only speaks HTTP/2 that way — the plaintext h2c mode above exists mainly for internal service-to-service traffic and for demos exactly like this one.
Mental Model
Section titled “Mental Model”Every response is a contract with exactly two required steps: send the headers once (writeHead, or implicitly on the first write/end), then send the body and close it once (end). Everything in this section is a variation on “what happens when that contract is broken”: broken by calling a step twice (ERR_HTTP_HEADERS_SENT, the double-end() crash), broken by never finishing it (a hanging client, the timeouts), or broken by something outside your handler entirely (EADDRINUSE before a single request even arrives). An 'error' listener does not prevent any of these — it just decides whether breaking the contract takes down one request or the whole process.
Trade-offs
Section titled “Trade-offs”Handling every error path shown here — res.on('error'), try/catch around writeHead, an EADDRINUSE guard, a graceful-shutdown handler — is real code you have to write and maintain for every server, and most of it never fires in normal operation. Skipping it costs nothing until the one time a client disconnects mid-response or a deploy sends SIGTERM mid-request, at which point the cost is an unhandled crash or a dropped request in production. There is no free lunch here: frameworks bake in reasonable defaults for exactly these cases, which is a large part of what you are actually paying for when you add one.
Common Misconceptions
Section titled “Common Misconceptions”- “An error inside a request handler only affects that one request.” Only if something is listening for it. An unhandled
'error'event onreq,res, or the server itself throws and crashes the entire process — every other in-flight request goes down with it. - “
ERR_HTTP_HEADERS_SENTand the double-res.end()crash are the same bug.” They are related but distinct: writing headers/body twice throws synchronously and is trivially catchable; callingres.end()twice fails asynchronously as a stream error, which needs an'error'listener, not atry/catch, to avoid crashing. - “Content-Length is just
str.length.” Only for pure ASCII text. Any multi-byte character (Thai, emoji, most non-English scripts) makes.length(UTF-16 code units) smaller than the real byte count — useBuffer.byteLengthfor anything you declare a length for by hand.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated handlers often end every branch of an if/else with its own res.end() call, which looks safe — until a later edit adds a code path after the if/else block that assumes the response has not been sent yet, and now res.end() gets called a second time.
🐛 Spot the bug
function handleRequest(req, res, isAdmin) {if (isAdmin) { res.writeHead(200); res.end('admin dashboard');} else { res.writeHead(403); res.end('forbidden');}
// Added later: log every request after handling itconsole.log('handled', req.url);if (req.url === '/admin/audit') { res.writeHead(200); res.end('audit log'); // BUG: res.end() was already called above for this same request}}Show the answer
Both branches of the original if/else correctly call res.end() exactly once — the bug is the code added later, which assumes it is safe to call res.writeHead/res.end again for a specific URL, without checking whether the response already finished. For /admin/audit (which is also isAdmin), res.end() now runs twice on the same response, which is exactly the ERR_STREAM_WRITE_AFTER_END crash from earlier in this lesson if nothing has a res.on('error', ...) listener. Any code added after a response might already be complete needs an early return (or a res.writableEnded check) before touching res again — never assume “this is the only place that responds.”