Why Node.js matters
Node.js is a runtime that runs JavaScript outside the browser. Released in 2009, it took the V8 engine from Chrome and paired it with APIs for files, networking and processes, letting developers use one language across the whole stack.
Its defining characteristic is non-blocking, event-driven I/O. Instead of reserving a thread per request and waiting while the disk or network responds, Node hands off the slow operation and continues with other work. When the operation finishes, a callback runs. That model makes it efficient for APIs, real-time apps and tools, and it is the same event loop you already met in browser JavaScript.
Running JavaScript on the server
Node gives you a global process, module system and standard library. A minimal HTTP server needs no dependencies.
// server.js
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404).end("Not found");
});
server.listen(process.env.PORT ?? 3000);
The built-in node: prefixed imports make it clear which modules come from Node itself. This server handles requests as they arrive and never blocks on I/O.
Modules
Modern Node uses ES modules, the same import and export syntax as the browser.
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from "./math.js";
Set "type": "module" in package.json to treat .js files as ES modules. Node also still supports CommonJS (require and module.exports), which you will encounter in older code and many packages. New projects should use ES modules.
The file system
Node’s file system API comes in async and sync variants. Use the async API in servers.
// files.js
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const file = path.join(import.meta.dirname, "data.json");
const data = JSON.parse(await readFile(file, "utf8"));
await writeFile(file, JSON.stringify({ ...data, updated: Date.now() }, null, 2));
The node:path module handles cross-platform path joining, and import.meta.dirname gives the current file’s directory. Sync functions like readFileSync exist and are fine in scripts and startup code, but never inside a request handler.
Environment and configuration
Configuration belongs in environment variables, not in code.
// env.js
const config = {
port: Number(process.env.PORT ?? 3000),
databaseUrl: process.env.DATABASE_URL,
nodeEnv: process.env.NODE_ENV ?? "development",
};
This keeps secrets out of the repository and lets the same build run in development, staging and production. Load local values from a .env file in development with a tool like dotenv or Node’s built-in --env-file, and never commit that file.
Building APIs
Most Node apps use a framework to handle routing, parsing and middleware. Express is the most widely used; Fastify is faster and schema-oriented.
// api.js
import express from "express";
const app = express();
app.use(express.json());
app.get("/api/users", async (req, res, next) => {
try {
const users = await db.users.findMany();
res.json(users);
} catch (error) {
next(error);
}
});
app.post("/api/users", async (req, res, next) => {
try {
const user = await db.users.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ message: "Something went wrong" });
});
app.listen(config.port);
Route handlers are async functions, errors are forwarded to middleware, and a final error handler turns exceptions into responses. Return meaningful status codes and headers, and see Web Security for validating input and protecting sessions.
Streams, buffers and events
Node handles data as streams so large payloads do not have to fit in memory.
// stream.js
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
createServer((req, res) => {
createReadStream("movie.mp4").pipe(res);
}).listen(3000);
A buffer is raw binary data, and an event emitter lets objects publish events that others subscribe to. Streams and events are the low-level machinery behind much of Node’s standard library, and understanding them explains how files, sockets and HTTP work under the hood.
Errors and process signals
Unhandled errors can crash a Node process, so handle them deliberately.
// signals.js
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection", reason);
});
SIGTERM is sent by most platforms when a container or process should stop; handling it lets you finish in-flight requests before exiting. Global handlers are a safety net for logging, not a substitute for handling errors where they occur.
Best practices
- Use async APIs in request handlers and never block the event loop.
- Keep configuration in environment variables.
- Validate and sanitise all input on the server.
- Use ES modules and the
node:prefix for built-ins. - Centralise error handling in middleware.
- Stream large files instead of reading them into memory.
- Handle
SIGTERMfor graceful shutdown and expose a health endpoint.
Common mistakes
- Using
readFileSyncinside a request handler and stalling the server. - Doing CPU-heavy work on the main thread.
- Committing
.envfiles or hardcoding secrets. - Trusting client input without validation.
- Ignoring unhandled promise rejections.
- Reading large files entirely into memory instead of streaming.
Where to go next
Node.js completes the full-stack picture. Manage dependencies with npm, understand the HTTP protocol your server speaks, secure it with Web Security, and package it with Docker. Then build a small API with two routes, a database call and an error handler, and deploy it.