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 http server fires the request handler synchronously with the request line and headers already parsed.
  • The handler decodes and normalizes req.url into 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() (or createReadStream directly) 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 with res.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), pipeline rejects 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.url directly.
  • Stream files with fs.createReadStream and pipeline() instead of reading whole files into memory, especially for anything larger than a few hundred KB.
  • Set an accurate Content-Type based on file extension; browsers may refuse to run scripts or apply styles served with the wrong MIME type.
  • Set Cache-Control headers on static assets (especially fingerprinted bundles) so browsers don’t re-download unchanged files on every visit.
  • In production, prefer express.static or 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 .env files can never be requested.
  • Always attach an error listener (or use pipeline) on any stream you pipe into a response.

Practice Exercises

  • Extend Example 2 so that requesting a path with no file extension (like /about) tries about.html before 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 of path.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 like express.static.
  • Stream files with fs.createReadStream piped through pipeline() 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-Type and Cache-Control headers, and always attach error handling to any stream piped into a response.