Serving Static Files
A “static file” is any file that Node.js sends to the browser exactly as it is stored on disk: an HTML page, a CSS stylesheet, a client-side JavaScript bundle, an image, a font. Unlike server-rendered content, a static file doesn’t change per request, so serving it well is mostly about reading it efficiently, telling the browser what kind of file it is, and not accidentally exposing files outside the folder you meant to share. Node has no built-in “static file server” the way a plain web server like nginx does — you either build one yourself on top of fs and http, or you use middleware like Express’s express.static. Understanding how to do it by hand is what makes the middleware version make sense.
Overview / How it works
Serving a static file over HTTP boils down to four steps: figure out which file the request is asking for, check that the file exists and is safely inside your public folder, tell the client what kind of file it is via the Content-Type header, and send the bytes. The interesting part is how you send the bytes.
The naive approach reads the whole file into memory with fs.readFile (or worse, the blocking fs.readFileSync) and then calls res.end(data). This works fine for small files, but it has two costs: the entire file has to sit in memory before the first byte reaches the client, and for a very large file that’s wasteful and slow. The correct approach for anything beyond small text files is to stream the file: open a readable stream with fs.createReadStream and pipe it directly into the writable HTTP response stream. Node reads the file in chunks (64 KB by default) and forwards each chunk to the network as soon as it’s read, instead of waiting for the whole file.
Streaming also gives you backpressure for free. If the client’s network connection is slow, the response stream fills up and signals the file stream to pause reading until the client catches up. This keeps memory usage flat no matter how big the file is or how slow the client is — Node isn’t buffering gigabytes of file content waiting for a dial-up connection to catch up.
Under the hood, fs.createReadStream uses libuv’s thread pool for the actual disk reads (disk I/O isn’t natively async on most operating systems the way socket I/O is), so file reads don’t block the event loop even though they’re implemented with worker threads behind the scenes. This is different from fs.readFileSync, which runs synchronously on the main thread and blocks the entire event loop — every other request, timer, and callback waits until the read finishes.
The other half of the job is security. A request’s URL is untrusted input. If you naively concatenate it onto your public directory path, a request like GET /../../etc/passwd can walk out of your intended folder and read arbitrary files from the server’s disk. Any static file handler you write must normalize and validate the resolved path before touching the filesystem.
Syntax
There’s no single built-in “serve a file” function; the general shape of a hand-rolled static handler looks like this:
http.createServer(async (req, res) => {
const filePath = resolveSafely(req.url, PUBLIC_DIR);
const stat = await fs.promises.stat(filePath);
res.writeHead(200, { "Content-Type": mimeFor(filePath), "Content-Length": stat.size });
await pipeline(fs.createReadStream(filePath), res);
});
| Piece | Purpose |
|---|---|
path.normalize() |
Collapses .. and . segments in the requested path |
path.join(PUBLIC_DIR, requested) |
Builds the absolute path inside your public folder |
filePath.startsWith(PUBLIC_DIR) |
Confirms the resolved path didn’t escape the public folder |
fs.promises.stat() |
Confirms the file exists and gets its size for Content-Length |
fs.createReadStream() |
Opens the file as a readable stream, in chunks |
pipeline() (from node:stream/promises) |
Pipes the file stream into the response and forwards/cleans up errors |
Examples
Example 1: A basic static server
This version is the simplest correct implementation: it reads whole files into memory, which is fine for a small site of HTML/CSS/JS files, and it looks up the Content-Type from the file extension.
const http = require("node:http");
const path = require("node:path");
const fs = require("node:fs/promises");
const PUBLIC_DIR = path.join(__dirname, "public");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
};
const server = http.createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent(req.url.split("?")[0]);
const requested = urlPath === "/" ? "/index.html" : urlPath;
const filePath = path.join(PUBLIC_DIR, path.normalize(requested));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
const data = await fs.readFile(filePath);
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
} catch (err) {
if (err.code === "ENOENT") {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
} else {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
}
}
});
server.listen(3000, () => {
console.log("Static server running at http://localhost:3000");
});
Output:
Static server running at http://localhost:3000
Requesting / serves public/index.html; requesting /style.css serves that file with a text/css content type. The path.normalize + startsWith check stops a request like /../server.js from resolving outside PUBLIC_DIR.
Example 2: Streaming large files with backpressure
For a real site — especially one serving images or video — you don’t want to hold the whole file in memory. This version streams the file and adds a Cache-Control header so browsers can cache assets.
const http = require("node:http");
const path = require("node:path");
const fsp = require("node:fs/promises");
const { createReadStream } = require("node:fs");
const { pipeline } = require("node:stream/promises");
const PUBLIC_DIR = path.join(__dirname, "public");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
};
async function resolveFile(urlPath) {
const decoded = decodeURIComponent(urlPath.split("?")[0]);
const clean = path.normalize(decoded === "/" ? "/index.html" : decoded);
const filePath = path.join(PUBLIC_DIR, clean);
if (!filePath.startsWith(PUBLIC_DIR)) {
throw Object.assign(new Error("Path escapes public directory"), { statusCode: 403 });
}
return filePath;
}
const server = http.createServer(async (req, res) => {
try {
const filePath = await resolveFile(req.url);
const stat = await fsp.stat(filePath);
if (!stat.isFile()) {
throw Object.assign(new Error("Not a file"), { statusCode: 404 });
}
const ext = path.extname(filePath);
res.writeHead(200, {
"Content-Type": MIME_TYPES[ext] ?? "application/octet-stream",
"Content-Length": stat.size,
"Cache-Control": "public, max-age=3600",
});
await pipeline(createReadStream(filePath), res);
} catch (err) {
const statusCode = err.statusCode ?? (err.code === "ENOENT" ? 404 : 500);
res.writeHead(statusCode, { "Content-Type": "text/plain" });
res.end(statusCode === 404 ? "Not Found" : "Internal Server Error");
}
});
server.listen(3000, () => {
console.log("Streaming static server running at http://localhost:3000");
});
Output:
Streaming static server running at http://localhost:3000
The key line is await pipeline(createReadStream(filePath), res). pipeline connects the file stream to the response, automatically respects backpressure (pausing the file read if the client is slow to receive data), and — unlike a bare .pipe() call — forwards any error from the file stream into a rejected promise you can catch, and makes sure both streams are properly closed.
Example 3: Serving static files with Express
In real applications you’ll almost always reach for express.static instead of hand-writing this logic. It does everything above — streaming, MIME type lookup, caching headers, path-traversal protection, conditional requests with ETag/If-None-Match — and is battle-tested.
npm install express
const express = require("express");
const path = require("node:path");
const app = express();
const PUBLIC_DIR = path.join(__dirname, "public");
app.use(express.static(PUBLIC_DIR, {
maxAge: "1h",
index: "index.html",
}));
app.use((req, res) => {
res.status(404).json({ error: "Not Found" });
});
app.listen(3000, () => {
console.log("Express static server running at http://localhost:3000");
});
Output:
Express static server running at http://localhost:3000
express.static is middleware: for every request it checks whether a matching file exists under PUBLIC_DIR; if so it streams the file and ends the response, and if not it calls next() so the request falls through to your other routes — in this case, the catch-all 404 handler.
How it works step by step
- A request arrives and Node’s
httpserver fires the request handler synchronously with the request line and headers already parsed. - The handler decodes and normalizes
req.urlinto a filesystem path, and validates that the resolved path is still inside the public directory — this check must happen before any filesystem call. fs.promises.stat()(orcreateReadStreamdirectly) queues an async filesystem operation on libuv’s thread pool; the event loop is free to handle other requests while the disk read happens.- Once the stat/open completes, the callback resumes: headers (
Content-Type,Content-Length, cache headers) are written withres.writeHead()— this must happen before any body bytes are sent. pipeline()starts reading the file in chunks and writing each chunk to the response socket. If the socket’s write buffer fills up (a slow client), the read stream is paused automatically until the buffer drains.- When the last chunk is read, the readable stream ends,
res.end()is called implicitly by the pipeline, and the response completes. If an error occurs at any point (file deleted mid-read, client disconnects),pipelinerejects and both streams are destroyed and cleaned up.
Common Mistakes
1. Trusting the URL directly (path traversal)
Concatenating the request URL onto your public directory without normalizing it lets an attacker read files outside that folder.
// DANGEROUS: trusts the URL directly
const filePath = PUBLIC_DIR + req.url;
fs.createReadStream(filePath).pipe(res);
// A request like GET /../../../../etc/passwd can escape PUBLIC_DIR
Always normalize the path and verify it still starts with your public directory before touching the filesystem:
const requestedPath = path.normalize(decodeURIComponent(req.url));
const filePath = path.join(PUBLIC_DIR, requestedPath);
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403);
res.end("Forbidden");
} else {
fs.createReadStream(filePath).pipe(res);
}
2. Reading large files synchronously
fs.readFileSync blocks the entire event loop until the read finishes — every other in-flight request, timer, and callback has to wait, so one big file read stalls your whole server.
// Blocks the event loop for every request until the file is fully read
const data = fs.readFileSync(filePath);
res.end(data);
Use a stream (or at minimum the async fs/promises API) so the read happens off the main thread via libuv’s thread pool:
const stream = fs.createReadStream(filePath);
await pipeline(stream, res);
3. Not handling stream errors
A bare .pipe() call does not forward errors from the source stream. If the file is deleted or becomes unreadable mid-request, the error is silently dropped, the response hangs, and in some Node versions an uncaught error can crash the process.
// If the file disappears mid-read, this error goes unhandled
fs.createReadStream(filePath).pipe(res);
Listen for the error event explicitly, or better, use pipeline() which does this for you:
const stream = fs.createReadStream(filePath);
stream.on("error", (err) => {
res.writeHead(500);
res.end("Internal Server Error");
});
stream.pipe(res);
Best Practices
- Always validate and normalize the resolved file path against your public directory before reading — never trust
req.urldirectly. - Stream files with
fs.createReadStreamandpipeline()instead of reading whole files into memory, especially for anything larger than a few hundred KB. - Set an accurate
Content-Typebased on file extension; browsers may refuse to run scripts or apply styles served with the wrong MIME type. - Set
Cache-Controlheaders on static assets (especially fingerprinted bundles) so browsers don’t re-download unchanged files on every visit. - In production, prefer
express.staticor a CDN/reverse proxy over hand-rolled file serving — they already handle range requests, conditional GETs, and compression correctly. - Never serve your entire project directory; keep static assets in a dedicated folder like
public/so source code and.envfiles can never be requested. - Always attach an
errorlistener (or usepipeline) on any stream you pipe into a response.
Practice Exercises
- Extend Example 2 so that requesting a path with no file extension (like
/about) triesabout.htmlbefore returning a 404. - Add a simple in-memory cache to Example 1 that stores the file contents of the five most recently requested files, so repeat requests skip the disk read. Think about what happens if a file changes on disk after being cached.
- Rewrite the path-traversal check in Example 2 using
path.resolve()instead ofpath.join()+startsWith(), and explain in a comment why the check still needs to happen after resolving, not before.
Summary
- Node has no built-in static file server — you build one on
fs+http, or use middleware likeexpress.static. - Stream files with
fs.createReadStreampiped throughpipeline()rather than reading whole files into memory; disk reads run on libuv’s thread pool, so they don’t block the event loop. - Streaming gives you backpressure automatically, keeping memory flat regardless of file size or client speed.
- Always normalize and validate the resolved file path against your public directory to prevent path traversal attacks.
- Set correct
Content-TypeandCache-Controlheaders, and always attach error handling to any stream piped into a response.
