Suggest an editImprove this articleRefine the answer for “How to serve static files and improve performance in Express.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**`express.static()`** is Express middleware that serves HTML, CSS, JS, and images from a directory without custom route handlers. ```js app.use(compression()); // must come first app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1y', etag: true, })); ``` Add `compression()` before it to gzip responses. For user-uploaded files that need auth, use a custom route with `res.sendFile` instead. **Key point:** `compression()` before `express.static()`, not after.Shown above the full answer for quick recall.Answer (EN)Image**`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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.