We closed Module 3 with one sentence: the project knows how to read and write, but it does not know how to talk to anyone. Escena Viva has a catalog on disk, reports by month, hardened paths and pipelines that process sales in constant memory. All of that is consumed by a single person: whoever runs the command in their terminal.
That changes today. By the end of this lesson, any browser on your network will be able to ask Escena Viva for data.
And you will see right away that this is not unfamiliar territory. A Node HTTP server is an EventEmitter that emits request —the same on and emit from lesson 02-05—, starting it is an asynchronous operation confirmed by an event, and keeping the process alive is exactly the event loop reference-counting mechanism we studied in 02-01. The node:http module adds no new concepts: it connects the ones you already have to the network.
Contents
- HTTP in five minutes: request, response and a real exchange
http.createServer: the server is anEventEmitterserver.listen, thelisteningevent and the port- Escena Viva's first server
- Three ways to test it: browser,
curlandnode -e - Why the process no longer exits on its own
- Startup errors:
EADDRINUSEandEACCES - Graceful shutdown:
server.close()andSIGINT
- HTTP in five minutes: request, response and a real exchange
HTTP is a request and response protocol built on text. The client opens a TCP connection, sends a block of text in a very specific format, and the server replies with another block. That is all. Neither the client nor the server keeps any memory of what happened between one request and the next: HTTP is stateless, and everything that looks like state (sessions, carts, logged-in users) is built on top, as we will see in Module 8.
A request has four parts and a response three:
| Message | Part | Example | What it means |
|---|---|---|---|
| Request | Method | GET, POST, PUT, DELETE, HEAD |
The intent: read, create, replace, delete |
| Request | Path | /events/evt-001?format=json |
Which resource, with optional query parameters |
| Request | Headers | Accept: application/json |
Metadata: what I accept, who I am, what I send |
| Request | Body | {"sessionId":"ses-001-1"} |
Data. Optional; GET normally has none |
| Response | Status code | 200, 404, 500 |
How it went, in a three-digit number |
| Response | Headers | Content-Type: application/json |
Metadata about what I return |
| Response | Body | The JSON, the HTML, the poster bytes | The content. Optional (a 204 has none) |
Here is a complete exchange, exactly as it travels over the wire. The blank line is what separates the headers from the body, and it is mandatory:
GET /events HTTP/1.1
Host: localhost:3000
User-Agent: curl/8.5.0
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 74
Date: Tue, 14 Aug 2026 09:12:30 GMT
Connection: keep-alive
{
"events": 3,
"sessions": 7,
"availableTickets": 1189
}Notice two details that will save you hours of debugging later on:
- The path that reaches the server does not include the domain.
Host: localhost:3000travels in a separate header. That is why, as we will see in the next lesson,req.urlis/eventsand neverhttp://localhost:3000/events. - Headers are plain
Name: valuetext, case-insensitive, and they can repeat. Node always hands them to you normalized to lowercase.
The good news is that you are not going to write that text by hand: the node:http module parses it on arrival and generates it when you respond. You work with two objects, req and res.
http.createServer: the server is an EventEmitter
http.createServer: the server is an EventEmitterThe minimal server fits in four lines:
const http = require('node:http');
const server = http.createServer((req, res) => {
res.end('Hello from Escena Viva\n');
});
server.listen(3000);Save it as hello.js, run it with node hello.js and open http://localhost:3000. You already have a web server.
Now the important part: the function you pass to createServer is nothing special. http.Server inherits from net.Server, which inherits from EventEmitter. Passing the handler to the constructor is pure syntactic sugar: Node does a server.on('request', handler) for you. These two versions are exactly equivalent:
// Version A: the handler as an argument (the usual one)
const server = http.createServer((req, res) => {
res.end('hello');
});
// Version B: registering the listener by hand (identical to the above)
const server = http.createServer();
server.on('request', (req, res) => {
res.end('hello');
});Knowing this is not trivia: it means you can register several request listeners, and that everything you learned about EventEmitter applies here. A real use case is separating request logging from the logic:
// Runs before the other one: listeners are invoked in registration order.
server.on('request', (req) => console.error(`[request] ${req.method} ${req.url}`));
server.on('request', handleRequest); // The real logicDiagnostics go to stderr and data to stdout, as we have been doing since Module 1.
These are the server events that matter in this module:
| Event | When it is emitted | Arguments |
|---|---|---|
request |
A complete HTTP request arrives (headers; the body may still be travelling) | (req, res) |
listening |
The socket is already accepting connections | — |
connection |
A TCP connection opens, before any request | (socket) |
close |
The server has stopped accepting and no connections remain | — |
error |
Server failure, typically at startup | (error) |
clientError |
A client has sent something that is not valid HTTP | (error, socket) |
The difference between connection and request deserves a moment. They are not interchangeable: HTTP/1.1 keeps the connection open by default (Connection: keep-alive), so a browser loading a page with three images may open one connection and send four requests over it. Seeing it live makes the concept clear:
let connections = 0;
let requests = 0;
server.on('connection', () => console.error(`[tcp] connections: ${++connections}`));
server.on('request', () => console.error(`[http] requests: ${++requests}`));Reload the page a couple of times and you will see the request counter climb much faster than the connection counter. That detail comes back in section 8, when we try to shut the server down.
server.listen, the listening event and the port
server.listen, the listening event and the portlisten is what puts the server into listening mode. Its usual signature is listen(port, host, callback), and it is asynchronous: when the call returns, the socket is not ready yet.
That callback is not error-first: it is simply a listening listener registered with once. That is why this version does the same thing:
server.on('listening', () => {
const { address, port } = server.address();
console.error(`Listening on http://${address}:${port}`);
});
server.listen(3000, '127.0.0.1');server.address() returns { address, family, port } and only has a value after listening; before that it returns null. It is the correct way to find out the real port when you ask for port 0, which means "let the operating system pick a free one" —an essential trick in the automated tests of Module 9, where you cannot fix a port without risking collisions.
The second argument, the host, decides who may connect: '127.0.0.1' accepts connections only from this same machine, and '0.0.0.0' (the default value) accepts them on every network interface, which is what you need in containers or to test from your phone.
In development, 127.0.0.1 is the prudent choice: it keeps your half-finished server from being exposed to the coffee shop's network. In Docker, on the other hand, it is a classic mistake, because the container would not receive the traffic forwarded from outside (we will cover this in Module 11).
- Escena Viva's first server
Let's get to the real code. Create src/server/server.js. The rule we impose on ourselves from minute one is the one we have been defending all course long: no synchronous I/O inside the handler. The catalog is read with getCatalog(), which has been asynchronous since lesson 03-01.
// src/server/server.js
// Escena Viva's first HTTP server: responds with a summary of the catalog.
const http = require('node:http');
const { getCatalog } = require('../catalog-data.js');
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '127.0.0.1';
// The handler is ASYNCHRONOUS: there is a disk read inside it.
async function handleRequest(req, res) {
const events = await getCatalog();
const summary = {
events: events.length,
sessions: events.reduce((total, event) => total + event.sessionCount, 0),
totalCapacity: events.reduce((total, event) => total + event.totalCapacity, 0),
ticketsSold: events.reduce((total, event) => total + event.ticketsSold, 0)
};
summary.availableTickets = summary.totalCapacity - summary.ticketsSold;
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(summary, null, 2));
}
function createServer() {
// The handler is async: it returns a promise that NOBODY collects. If it fails,
// that would be an unhandledRejection (lesson 02-04), so it is caught here.
const server = http.createServer((req, res) => {
handleRequest(req, res).catch((error) => {
console.error('[error] unhandled failure:', error);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
}
res.end(JSON.stringify({ error: 'Internal server error' }, null, 2));
});
});
return server;
}
function main() {
const server = createServer();
server.listen(PORT, HOST, () => {
const { address, port } = server.address();
console.error(`[server] listening on http://${address}:${port}`);
});
}
if (require.main === module) main();
module.exports = { createServer, handleRequest, PORT, HOST };Four decisions that carry through the whole module:
createServer()is exported without starting it. Separating construction from startup is what will let us bring the server up on a random port inside a test (Module 9) without the file opening a port merely because it was imported. Therequire.main === modulecheck is what keeps it runnable withnode src/server/server.js.- The
.catchis not optional.http.createServercompletely ignores whatever the handler returns. If the handler isasyncand throws, the promise is left rejected with no owner: the client hangs until its timeout expires and, since Node 15, the process crashes withunhandledRejection. A single corrupt event in the JSON would take down the entire server. res.headersSentavoids theERR_HTTP_HEADERS_SENTerror: if the failure happened after you started writing the response, the headers can no longer be changed. It is the most common error in this module and we take it apart in 04-02.- The port comes from the environment.
process.env.PORTlets you change it without touching the code; in production the platform imposes it. Configuration by environment is the topic of lesson 11-01.
- Three ways to test it: browser,
curl and node -e
curl and node -eStart the server:
The browser is the fastest test (http://localhost:3000), but it is also the one that teaches you the least: you see neither headers nor status code, and it adds phantom requests such as /favicon.ico that pollute your logs. It is useful, but not enough.
curl -i http://localhost:3000/ shows the complete response, headers included. It is the tool we will use throughout the module:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Transfer-Encoding: chunked
{
"events": 3,
"sessions": 7,
"totalCapacity": 3000,
"ticketsSold": 1811,
"availableTickets": 1189
}There are the seed numbers: 3 events, 7 sessions, capacity 3000, 1811 sold and 1189 available. Note that there is no Content-Length but a Transfer-Encoding: chunked; in 04-02 we will explain exactly why and when each one is preferable.
curl flags we will use: -i includes the headers, -s silences the progress bar, -X forces the method, -H adds a header and -d sends a body.
And node -e, to test from Node itself with fetch (the client API we will study in depth in 04-06):
node -e "fetch('http://localhost:3000/').then(r => r.json()).then(d => console.log(d.availableTickets))"
# 1189
- Why the process no longer exits on its own
Run the server and notice something that had never happened in this course: the process does not end. Every previous script did its job and returned control to the terminal. This one just sits there, apparently doing nothing.
It is not magic, it is the event loop reference counting from lesson 02-01. Node exits when there are no pending operations left that could produce future work. A listening socket is exactly that: an active handle that can wake the loop at any moment. As long as it exists, the count never reaches zero and the process stays alive.
You can verify it by unreferencing it:
const server = http.createServer((req, res) => res.end('hello'));
server.listen(3000, () => console.error('listening'));
server.unref(); // "If this is ALL that's left, don't keep me alive"That program prints listening and exits immediately, without having served anything. The server was correctly started; it had simply stopped counting. unref() has legitimate uses (a secondary metrics server that must not prevent shutdown), but for the main server it is a guaranteed bug. ref() undoes the effect.
The practical consequence is that, from now on, your process has a lifecycle: it starts, serves for days and has to be able to shut down properly. That is what section 8 is about.
- Startup errors:
EADDRINUSE and EACCES
EADDRINUSE and EACCESStart the server in one terminal and, without stopping it, start it in another:
node:events:496
throw er; // Unhandled 'error' event
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000Recognize that message: Unhandled 'error' event is the one from lesson 02-05. An EventEmitter that emits error with no listeners throws the exception and takes down the process. http.Server is no exception to that rule.
The two startup failures you will see in practice:
| Code | Cause | Fix |
|---|---|---|
EADDRINUSE |
The port is already taken by another process (often your previous server) | Close the other process, or use another port |
EACCES |
Ports below 1024 require administrator privileges on Linux and macOS | Use a high port (3000, 8080) and put a reverse proxy in front in production |
EACCES surprises anyone who tries to listen on port 80 "because that's the HTTP one". On Unix-like systems, ports below 1024 are reserved privileges: only root can open them. The right answer is never to run Node as root, but to listen on a high port and let Nginx or the platform's load balancer listen on 80/443 (Module 11).
The fix is to register an error listener before calling listen, and to give a message people can understand:
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(`[server] port ${PORT} is already in use. Try: PORT=3001 node ${__filename}`);
} else if (error.code === 'EACCES') {
console.error(`[server] no permission for port ${PORT}. Use a port >= 1024.`);
} else {
console.error('[server] unexpected error:', error);
}
process.exitCode = 1;
});error.code (the system property) is what comes from libuv; do not confuse it with our domain error.appCode, which we will translate into HTTP statuses in the next lesson. And process.exitCode = 1 instead of process.exit(1): let the process finish naturally, flushing its output buffers, as we already saw in Module 1.
To find the culprit behind an EADDRINUSE on Linux or macOS: lsof -i :3000 tells you which process holds the port, and kill <PID> frees it.
- Graceful shutdown:
server.close() and SIGINT
server.close() and SIGINTWhen you press Ctrl+C, your terminal sends the SIGINT signal to the process, and Node's default behavior is to die on the spot. That is acceptable in a script; in a server, it means cutting in-flight requests off mid-sentence: a client is left without its response, and a file write can be left half done.
Graceful shutdown means stopping the acceptance of new connections and waiting for the live ones to finish:
function installGracefulShutdown(server) {
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return; // A second Ctrl+C must not re-enter here
shuttingDown = true;
console.error(`\n[server] received ${signal}, closing...`);
// 1. Stop accepting NEW connections. The callback is called
// when the last live connection has closed.
server.close((error) => {
if (error) process.exitCode = 1;
console.error(error ? `[server] error while closing: ${error.message}` : '[server] closed cleanly');
});
// 2. Close idle keep-alive connections, which would otherwise
// keep the process alive until their timeout.
server.closeIdleConnections();
// 3. Safety net: if it has not closed in 10 s, force it.
const deadline = setTimeout(() => {
console.error('[server] deadline expired, forcing shutdown');
server.closeAllConnections();
process.exit(1);
}, 10_000);
deadline.unref(); // This timer must not keep the process alive
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}The delicate point is number 2, and it comes from what we saw in section 2: close() does not close existing connections, only the listening socket. With keep-alive, a browser that has asked you for a page leaves its TCP connection open for a few seconds in case it needs something else. Those idle connections are not serving anything, but they count as live, so close() sits there waiting and it looks like your server "won't close". closeIdleConnections() (Node 18.2+) handles exactly that case: it closes the ones with no request in flight and respects the ones that have one.
About the signals: SIGINT is your Ctrl+C; SIGTERM is the one Docker, systemd and deployment platforms send to ask for an orderly shutdown, with a grace period after which they send SIGKILL, which cannot be caught. Hence the safety timer: better to close yourself in 10 seconds than to be killed at 30 leaving files half written. In Module 11 we will come back to this with PM2 and Docker.
Common Mistakes and Tips
- Forgetting
res.end(). The client waits forever. In the browser it looks like a tab spinning endlessly, and incurllike a response that never arrives. Every execution path of the handler must finish with anend(). - Doing synchronous I/O in the handler. A
readFileSyncon one route blocks the event loop and therefore every client at once, not just the one who asked for it. It is acceptable at startup; never insiderequest. - An
asynchandler with no.catch. Rejected promise with no owner, hanging client and a crashed process. Always wrap it, the waycreateServer()does. - Not listening to the server's
errorevent. AnEADDRINUSEtakes down your process with an incomprehensible stack trace instead of a useful message. - Confusing
connectionwithrequest. Withkeep-alive, one connection serves many requests. Counting connections is not counting visits. - Tip: export
createServer()without starting it and start it only underrequire.main === module. Your Module 9 self, writing integration tests, will thank you. - Tip: during development,
node --watch src/server/server.jsrestarts the process when you save. No dependencies and nonodemon.
Exercises
Exercise 1: connection and request counter
Extend src/server/server.js so it keeps a count of TCP connections and of HTTP requests served, and exposes them in the JSON summary under the keys connections and requests. Then, with the server running, run curl three times in a row and afterwards reload the browser page three times. Explain why the two numbers do not grow the same way.
Exercise 2: robust startup
Write src/server/start.js that takes the port from process.argv (with process.env.PORT as a second option and 3000 as a third), starts the server and handles the three cases: successful startup (prints the real URL obtained from server.address()), EADDRINUSE (clear message and exitCode 1) and EACCES. Test it with ports 3000, 80 and with two instances at once.
Exercise 3: measuring the graceful shutdown
Add a slow route to the handler: if req.url is /slow, wait 5 seconds with sleep (from src/utils/sleep.js) before responding. Run curl http://localhost:3000/slow and, while it waits, press Ctrl+C on the server. Check that the request completes and that only afterwards does closed cleanly appear. Repeat with closeIdleConnections() removed and explain what changes.
Solutions
Solution 1. The counters are server state, so they live in createServer:
function createServer() {
const stats = { connections: 0, requests: 0 };
const server = http.createServer((req, res) => {
stats.requests++;
handleRequest(req, res, stats).catch(/* ... */);
});
server.on('connection', () => { stats.connections++; });
return server;
}Three curl calls produce 3 connections and 3 requests: curl closes its connection when each invocation finishes. Three browser reloads normally produce 1 connection and 6 requests or more: the browser reuses the connection thanks to keep-alive and, on top of that, asks for /favicon.ico on its own. That asymmetry is precisely the reason closeIdleConnections() exists.
Solution 2. The key is that the error listener must be registered before listen, because the failure is emitted asynchronously but immediately:
const port = Number(process.argv[2]) || Number(process.env.PORT) || 3000;
const server = createServer();
server.on('error', (error) => {
const messages = {
EADDRINUSE: `Port ${port} is taken. Try: node src/server/start.js 3001`,
EACCES: `No permission for port ${port}. Use one >= 1024.`
};
console.error(`[server] ${messages[error.code] ?? error.message}`);
process.exitCode = 1;
});
server.listen(port, '127.0.0.1', () => {
const { address, port } = server.address();
console.error(`[server] listening on http://${address}:${port}`);
});With port 80 and no privileges you get EACCES; with two instances on 3000, the second one gives EADDRINUSE. Without the listener, both cases would be a stack dump with Unhandled 'error' event.
Solution 3. With closeIdleConnections() you will see the slow request complete and the process finish an instant later. Without it, if you have used the browser beforehand, the process hangs until the keep-alive expires (5 seconds by default, server.keepAliveTimeout), or even until the forced 10-second deadline if the browser renews the connection. It is the exact symptom of "my server won't close with Ctrl+C", and now you know the fault is not in your code, but in an idle connection that is still counting.
Conclusion
Escena Viva is on the network. And it got there with no new concepts: http.createServer returns an EventEmitter that emits request —it makes no difference whether you pass the handler to the constructor or register it with on—, plus connection, listening, close and that error which, if you do not listen for it, takes down your process just as in lesson 02-05. listen(port, host, callback) is asynchronous and its callback is nothing more than a once('listening'); server.address() gives you the real port, essential when you ask for port 0.
You have the first real server in src/server/server.js, with the discipline we will keep all module long: an asynchronous handler from minute one, a mandatory .catch so that a rejected promise does not cost you the process, a port configurable with process.env.PORT and createServer() exported without starting. You know how to test it with the browser, with curl -i —which shows the headers— and with node -e. You understand why the process no longer exits on its own: the listening socket keeps the event loop's reference count up, and unref() proves it by switching that off. And you know how to start and stop it properly: EADDRINUSE and EACCES caught with useful messages, and a graceful shutdown with SIGINT/SIGTERM, server.close() and closeIdleConnections(), because persistent connections do not close by themselves.
What our server does, however, is embarrassing: it answers the same thing to /, to /events and to /anything-at-all, always with a 200, without looking at the method or the parameters. It is time to really read what reaches us and to build carefully what we return. In the next lesson, Handling Requests and Responses, we take apart req —which is a readable stream— and res —which is a writable stream—, we learn to parse the URL without slicing strings by hand, we review the status codes the whole course will use and we build src/server/responses.js with the table that maps our domain error.appCode values to HTTP statuses.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
