Serving static files, images, stylesheets, client-side JavaScript, is a common server responsibility even in API-focused apps. This lesson shows how to serve them safely with plain Node.js.
Serving a File From Disk
Serving a static file means reading it from disk and writing its contents to the response, with an appropriate Content-Type header so the browser knows how to interpret it (an image, a stylesheet, plain text, and so on).
Streaming the file with fs.createReadStream() and .pipe() (rather than reading the whole file into memory first) keeps memory usage low, especially important for large assets like videos or bundled JavaScript files.
Piping the file stream directly into res streams it to the client chunk by chunk, and the 'error' handler catches missing files cleanly instead of crashing.
Browsers rely on Content-Type to decide how to render or execute a file.
path.extname() gives you the file extension needed to look up the right MIME type.
'application/octet-stream' is a safe fallback for unknown file types (triggers a download).
Package managers offer libraries like mime-types if you don't want to maintain your own lookup table.
Static Files Cheatsheet
Common file extensions and their correct Content-Type values.
Extension
Content-Type
.html
text/html
.css
text/css
.js
application/javascript
.json
application/json
.png / .jpg / .svg
image/png, image/jpeg, image/svg+xml
.pdf
application/pdf
Preventing Path Traversal Attacks
A naive static file server that joins user-supplied paths directly can be tricked into reading files outside its intended directory using ../ segments (a "path traversal" attack), potentially exposing sensitive files elsewhere on the server.
import path from 'node:path';
function resolveSafePath(publicDir, requestedPath) {
const resolved = path.join(publicDir, requestedPath);
if (!resolved.startsWith(publicDir)) {
throw new Error('Path traversal attempt blocked');
}
return resolved;
}
Always verify the resolved path still starts with your intended public directory before reading the file, this single check closes off an entire class of vulnerabilities.
Caching Headers for Static Assets
Production servers typically add caching headers (Cache-Control, ETag) to static assets so browsers avoid re-downloading unchanged files on every visit, a meaningful performance win for CSS, JavaScript, and images.
Cache-Control: max-age=31536000, immutable for versioned/fingerprinted assets.
Shorter cache lifetimes (or none) for frequently-changing files like index.html.
In production, this is usually handled by Express's express.static() or a CDN rather than by hand.
Common Mistakes
Joining request paths directly into a file path without validating against path traversal (../../etc/passwd).
Forgetting to set Content-Type, causing browsers to guess incorrectly or download files instead of rendering them.
Reading entire large files into memory with fs.readFile() instead of streaming them.
Serving files without any caching headers, forcing browsers to re-download unchanged assets repeatedly.
Key Takeaways
Serving static files means reading them from disk and setting a matching Content-Type header.
Streaming with fs.createReadStream().pipe(res) avoids loading entire files into memory.
Always validate resolved file paths to prevent path traversal attacks.
Caching headers (Cache-Control, ETag) reduce redundant downloads for unchanged assets.
Pro Tip
Unless you have a specific reason to hand-roll static file serving, use express.static() (covered in the Express Static Files lesson) or a CDN in production, both already handle path traversal protection, caching headers, and range requests correctly.
You now know how to serve static files safely. Next, learn middleware, the pattern that lets you run shared logic across many routes.