Warming up the neural circuits...
By the end of this chapter you will:
Before reaching for a framework, write the thing the framework is hiding. Once you've written a vanilla Node HTTP server, every Express becomes obvious.
Imagine an old wartime radio operator. They sit by the equipment all day. When a signal comes in, they decode it, write down the message, look up the answer, and transmit back. They're idle 99% of the time — but they have to be there to catch every incoming signal.
That's what a server does. It sits on a port. Every time a TCP packet arrives, it decodes the HTTP request, runs your code, and sends the bytes back. The radio is http.createServer. The transmitting is res.end(...).
Start a new project:
mkdir first-server && cd first-server
npm init -yCreate server.js:
const http = require('http');
const server = http.createServer((req, res) => {
console.log(`${req.method} ${req
Run it:
node server.js
# Server running at http://localhost:3000In another terminal:
curl http://localhost:3000/
# Hello, backend!
curl http://localhost:3000/health
# {"status":"ok"}
curl -i http://localhost:3000/missing
# HTTP/1.1 404 Not Found
# {"error":"Not found"}That's a real, working HTTP server. Zero dependencies.
Let's walk through what each line does:
const http = require('http');Node ships with an http module. No install needed.
const server = http.createServer((req, res) => { ... });Create a server. The callback runs for every request. req represents the incoming request; res represents the response you'll send back.
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));Write the status line + headers, then the body. res.end() signals "done — send it." If you forget to call res.end(), the client hangs.
server.listen(PORT, () => { ... });Bind to a TCP port. The callback runs once the server is ready.
A computer has 65,535 ports. The first 1024 are "privileged" (need root/admin to bind). Common defaults:
| Port | Protocol |
|---|---|
| 80 | HTTP |
| 443 | HTTPS |
| 22 | SSH |
| 5432 | PostgreSQL |
| 6379 | Redis |
| 3000 | Node dev (convention) |
| 8080 | Alt HTTP (convention) |
You bind your server to port 3000 in dev because anything ≥ 1024 doesn't need admin. In production, nginx (on port 443) proxies to your Node server (on port 3000).
If you get EADDRINUSE, something else is on that port:
# macOS/Linux
lsof -i :3000
# Windows PowerShell
Get-NetTCPConnection -LocalPort 3000Node is single-threaded but non-blocking. This sounds wrong. Let's unpack.
When your handler does await db.query(...), the OS kernel does the actual disk read. Node doesn't sit and wait — it goes back to the event loop and serves other requests while it waits.
Rendering diagram…
Consequence: never block the event loop. If your handler does a CPU-heavy thing (image resize, big regex, sync file read), every other request waits.
app.get('/hash', (req, res) => {
const result = crypto.pbkdf2Sync(req.query.pw, 'salt', 100_000, 64, '
app.get('/hash', async (req, res) => {
const result = await new Promise((resolve, reject) => {
crypto.pbkdf2(req
The version hands the heavy work to the libuv thread pool. The event loop is free.
This is the whole reason Node uses async I/O — and why a sync fs.readFileSync in a hot path is a footgun.
Vanilla HTTP doesn't parse the body for you. Watch:
const server = http.createServer((req, res) => {
if (req.url === '/echo' && req.method === 'POST') {
let body
Look at all that code for one endpoint that parses JSON. Now imagine 50 endpoints. This is exactly why Express exists — it adds 5 lines to do this once for all routes.
Press Ctrl+C in the terminal. Node sends SIGINT and the process exits. But what if a request is mid-flight?
Production servers should:
function shutdown() {
console.log('Shutting down...');
server.close(() => {
console.log('No more requests, exiting.');
process.exit(0);
});
You'll see this pattern in every production Node app.
Vanilla HTTP works but is painful at scale. You'd write 50 lines for:
if (req.url === '...' && req.method === 'POST'))Express bundles all of that in a clean :
app.post('/echo', (req, res) => {
res.json({ youSent: req.body });
});That's it. The next chapter is Express.
| Mistake | Why it's wrong | What to do |
|---|---|---|
Forgetting res.end() | Client hangs forever | Always close the response |
| Sync FS / crypto in a handler | Blocks the event loop | Use async APIs |
| Hardcoding the port | Can't reuse in tests / different envs | Read from process.env.PORT |
| Logging req body on every call | Leaks PII | Log path/method/status, not bodies |
| Not handling SIGTERM | Deploys cause 500s | Implement graceful shutdown |
Production. Always have a /health (or /healthz) endpoint that returns 200 if your service is alive. Load balancers and k8s use this to know whether to send traffic.
Performance. Use keep-alive — TCP connections are expensive to set up. Node does this by default; just don't break it.
Security. Never trust req.url — it's untrusted . Use a router (next chapter) that handles path for you.
GET /time endpoint that returns the current time as ISO string.process.env.PORT || 3000. Run on a different port.autocannon to send 10,000 requests. Compare RPS for a /sync (blocking) and /async route.Beginner. What does http.createServer actually return? An EventEmitter that listens on a TCP port and emits a request event for each HTTP request.
Senior. Why is Node single-threaded but able to handle thousands of concurrent connections? The event loop multiplexes I/O. Slow operations are handed off to the OS kernel or libuv thread pool; the event loop processes other work in the meantime.
A Node HTTP server is http.createServer((req, res) => {...}).listen(port). The handler runs for every request. The event loop keeps Node fast as long as your code doesn't block. You'll outgrow vanilla HTTP fast — Express adds routing, body parsing, and middleware in 10 lines.
http.createServer((req, res) => { ... }).listen(port) is the whole server.res.end().process.env.PORT.res.end() do?fs.readFileSync in a request handler bad?