Skip to main content

How to serve static files and improve performance in Express.js?

express.static() - built-in Express middleware that serves files like HTML, CSS, JS, and images directly from a directory, without writing a route handler for each file.

Theory

TL;DR

  • Think of it like a self-serve buffet: guests grab food themselves instead of ordering from the kitchen. Static files go straight from disk to browser, skipping your route logic entirely.
  • express.static() streams files via Node's fs.createReadStream, bypassing Express's parser stack.
  • ETag headers let browsers skip re-downloading on repeat visits (304 Not Modified instead of 200).
  • Use it for CSS/JS/images; skip it for user-uploaded files that need auth checks.
  • Biggest performance gain: put compression() before it and set maxAge for browser caching.

Quick example

js
const express = require('express'); const path = require('path'); const app = express(); // Serve from 'public' - /style.css maps to public/style.css app.use(express.static(path.join(__dirname, 'public'))); app.listen(3000); // GET /style.css → 200 OK, serves public/style.css // GET / → 200 OK, serves public/index.html (if exists) // GET /api/users → falls through to next middleware (no file match)

One thing to notice: path.join(__dirname, 'public') instead of just 'public'. That difference matters in production.

How it works internally

express.static() reads the request URL, finds a matching file in your directory, and streams it using fs.createReadStream. The key word is streams. Bytes go from disk directly into the response, never loading the whole file into RAM. That is why a 50MB video does not crash your server.

For caching, it generates an ETag by hashing the file content. On the next request, the browser sends If-None-Match: "abc123". Express checks if the file changed. If not, it replies 304 and sends zero bytes. Your users get fast loads, your bandwidth drops.

Dynamic routes like app.get('/api/data', handler) run code on every single request. Static middleware does not touch your app code at all for matched files.

When to use

  • Public CSS/JS/images: app.use(express.static('public'))
  • Versioned assets with a URL prefix: app.use('/v1', express.static('dist'))
  • SPA (React, Vue): serve the build folder, then catch all routes with res.sendFile('index.html')
  • Files that need auth: do NOT use express.static directly. Put auth middleware before it, or serve via res.sendFile after the check.
  • No frontend assets at all: skip it entirely.

Caching and compression options

js
const compression = require('compression'); // compression must come BEFORE express.static app.use(compression()); app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1y', // cache for 1 year (for hashed filenames) etag: true, // ETag header for cache validation lastModified: true, // Last-Modified header dotfiles: 'ignore', // never expose .env or .git files }));

Why compression before static? Compression wraps the response stream. If static goes first, it sends bytes before compression can intercept them. Order matters in Express middleware.

For Create React App builds (files have hashed names like main.abc123.js), maxAge: '1y' with immutable: true is the right call. The hash changes on each deploy, so old caches become invalid automatically. For plain HTML files, go the opposite direction: no caching, or very short.

js
// Long cache for hashed assets app.use('/static', express.static('public', { maxAge: '1y', setHeaders: (res, filePath) => { if (filePath.endsWith('.js')) { res.set('Cache-Control', 'public, immutable'); } } })); // No cache for HTML app.use((req, res, next) => { if (req.path.endsWith('.html')) { res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); } next(); });

Common mistakes

Mistake 1: Placing static before your API routes

js
// Wrong - might match /api/users if a file exists at that path app.use(express.static('public')); app.get('/api/users', handler); // never reached // Right app.get('/api/users', handler); app.use(express.static('public'));

Static middleware matches first and consumes the request. If public/api/users exists as a file, your route handler never runs.

Mistake 2: Relative path without __dirname

js
// Wrong - works in dev, breaks on deploy app.use(express.static('public')); // Right - always resolves from the script's directory app.use(express.static(path.join(__dirname, 'public')));

Plain 'public' resolves from process.cwd(), which changes depending on where you start the process. __dirname is always the directory of the file containing the code.

Mistake 3: No maxAge in production

js
// Wrong - every user redownloads your 2MB JS bundle on every visit app.use(express.static('public')); // Right app.use(express.static('public', { maxAge: '1y' })); // for hashed filenames

Mistake 4: Serving user uploads as static

js
// Wrong - anyone can access any uploaded file with no auth app.use('/uploads', express.static('uploads')); // Right - check auth first app.get('/uploads/:id', authMiddleware, (req, res) => { res.sendFile(path.join(__dirname, 'uploads', req.params.id)); });

This is one of the most common Express security holes. No auth check means every file in that folder is publicly accessible.

Real-world usage

  • Create React App: app.use(express.static(path.join(__dirname, 'build'))) is the standard production setup.
  • Next.js standalone mode: uses its own static handler with immutable caching for /_next/static.
  • Express + Socket.io: mount static first, then the socket handler, to avoid URL conflicts.
  • Under 10k req/s: Express static handles it fine. Above that, put nginx in front or push assets to a CDN like CloudFront or Cloudflare.

I have seen teams skip nginx entirely on low-traffic internal tools. It works. But once you are in production with real user load, nginx or a CDN saves both money and latency.

Follow-up questions

Q: What is the performance difference between express.static and a custom fs.readFile handler?
A: Static uses fs.createReadStream, which pipes data directly to the response without buffering. fs.readFile loads the entire file into memory first, which causes crashes on large files and increases RAM usage at scale.

Q: How does ETag work in the cache validation flow?
A: The server sends ETag: "abc123" (an MD5 hash of the file). The browser stores it and sends If-None-Match: "abc123" on the next request. Express checks if the hash matches, and if it does, replies 304 with no body.

Q: Why does compression() have to go before express.static()?
A: Compression wraps the outgoing response stream. Express.static generates that stream. If static runs first, bytes are already sent before compression can intercept them.

Q: How do you invalidate browser cache after a deploy?
A: Use filename hashing. Webpack and Vite add a content hash to filenames (like main.abc123.js). You set maxAge: '1y', and when the file changes, its name changes too. Old cache entries become stale automatically.

Q: How do you handle SPA routing (React Router) with express.static?
A: Serve the build folder first, then catch all remaining routes and send index.html. The client-side router handles navigation from there.

js
app.use(express.static(path.join(__dirname, 'build'))); app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'build/index.html')));

Examples

Basic: Serving a public folder with caching

js
const express = require('express'); const path = require('path'); const app = express(); app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h', // browser caches for 1 hour })); app.listen(3000); // First GET /logo.png → 200, Cache-Control: public, max-age=3600 // Second GET /logo.png → 304 (from browser cache, 0 bytes sent)

Files in public/ map directly to URLs. public/logo.png becomes /logo.png. public/js/app.js becomes /js/app.js. No route code involved.

Intermediate: Production React app with compression

js
const express = require('express'); const path = require('path'); const compression = require('compression'); const app = express(); // Gzip responses larger than 1kb app.use(compression()); // Serve React build - hashed filenames allow a 1-year cache app.use(express.static(path.join(__dirname, 'build'), { maxAge: '1y', etag: true, lastModified: true, })); // SPA fallback - React Router handles client-side navigation app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'build/index.html')); }); app.listen(3000); // /static/js/main.abc123.js → 200 gzip (45kb → 12kb), max-age=31536000 // Page refresh → 304, 0 bytes downloaded

Compression drops typical JS bundles from 45kb to around 12kb. Combined with a 1-year cache, repeat visitors get near-instant loads.

Advanced: Custom cache headers per file type

js
const express = require('express'); const path = require('path'); const compression = require('compression'); const app = express(); app.use(compression()); app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1y', setHeaders: (res, filePath) => { // Hashed JS/CSS: immutable, cache forever if (filePath.match(/\.(js|css)$/)) { res.set('Cache-Control', 'public, max-age=31536000, immutable'); } // Images: cache for 30 days if (filePath.match(/\.(png|jpg|svg|webp)$/)) { res.set('Cache-Control', 'public, max-age=2592000'); } } })); // API routes are unaffected app.get('/api/users', (req, res) => { res.json({ users: [] }); }); app.listen(3000);

setHeaders gives per-file control without writing a separate route for each type. Hashed files get immutable so browsers never revalidate them. Images get a shorter cache since they can change without a name change.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?