Skip to main content

Як обслуговувати статичні файли та покращити продуктивність в Express.js?

express.static() - вбудований middleware Express, який роздає файли HTML, CSS, JS та зображення прямо з директорії, без жодного окремого route-хендлера для кожного файлу.

Теорія

TL;DR

  • Уявіть шведський стіл: гості самі беруть їжу замість того, щоб чекати на кухню. Статичні файли йдуть з диску прямо в браузер, минаючи всю логіку роутів.
  • express.static() передає файли через fs.createReadStream, обходячи стек парсерів Express.
  • Заголовки ETag дозволяють браузерам пропускати повторне завантаження (304 Not Modified замість 200).
  • Головне правило: використовуй для CSS/JS/зображень, не використовуй для файлів користувачів без перевірки прав.
  • Найбільший виграш у продуктивності: постав compression() перед ним і встанови maxAge для кешування.

Швидкий приклад

js
const express = require('express'); const path = require('path'); const app = express(); // Роздаємо з папки 'public' - /style.css відповідає public/style.css app.use(express.static(path.join(__dirname, 'public'))); app.listen(3000); // GET /style.css → 200 OK, роздає public/style.css // GET / → 200 OK, роздає public/index.html (якщо є) // GET /api/users → передає далі до наступного middleware (файл не знайдено)

Зверни увагу: path.join(__dirname, 'public') замість просто 'public'. Ця різниця дає про себе знати на деплої.

Як це працює всередині

express.static() зчитує URL запиту, шукає відповідний файл і передає його через fs.createReadStream. Ключове слово тут - стрімінг (streaming). Байти йдуть з диску прямо у відповідь, без завантаження файлу в пам'ять. Саме тому відео на 50MB не кладе сервер.

Для кешування middleware генерує ETag, хешуючи вміст файлу. При наступному запиті браузер надсилає If-None-Match: "abc123". Express перевіряє, чи змінився файл. Якщо ні - відповідає кодом 304 і не надсилає жодного байта. Користувачі отримують швидке завантаження, ти економиш трафік.

Динамічні роути на зразок app.get('/api/data', handler) виконують код на кожен запит. Статичний middleware для знайдених файлів взагалі не торкається твого коду.

Коли використовувати

  • Публічні CSS/JS/зображення: app.use(express.static('public'))
  • Версіоновані ресурси з URL-префіксом: app.use('/v1', express.static('dist'))
  • SPA (React, Vue): роздаємо папку з білдом, потім перехоплюємо всі роути і відправляємо index.html
  • Файли з перевіркою прав: не використовуй express.static напряму. Постав auth middleware перед ним або роздавай через res.sendFile після перевірки.
  • Фронтенду немає зовсім: пропускай. Трохи менше часу на старт.

Налаштування кешування та стиснення

js
const compression = require('compression'); // compression має бути ДО express.static app.use(compression()); app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1y', // кешувати на 1 рік (для хешованих імен файлів) etag: true, // заголовок ETag для перевірки кешу lastModified: true, // заголовок Last-Modified dotfiles: 'ignore', // ніколи не відкривати .env або .git }));

Чому compression перед express.static? Compression обгортає стрім відповіді. Якщо статика стоїть першою, байти вже відправлені до того, як compression встигне їх перехопити. Порядок middleware в Express важливий.

Для білдів Create React App (файли мають хешовані імена на зразок main.abc123.js), maxAge: '1y' разом з immutable: true - правильне рішення. Хеш змінюється при кожному деплої, тому старий кеш стає недійсним автоматично. Для звичайних HTML-файлів - навпаки: без кешу або з коротким.

js
// Довгий кеш для хешованих статичних ресурсів app.use('/static', express.static('public', { maxAge: '1y', setHeaders: (res, filePath) => { if (filePath.endsWith('.js')) { res.set('Cache-Control', 'public, immutable'); } } })); // Без кешу для HTML app.use((req, res, next) => { if (req.path.endsWith('.html')) { res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); } next(); });

Типові помилки

Помилка 1: Статика поставлена до API-роутів

js
// Неправильно - може перехопити /api/users, якщо файл існує app.use(express.static('public')); app.get('/api/users', handler); // ніколи не виконається в такому разі // Правильно app.get('/api/users', handler); app.use(express.static('public'));

Статичний middleware першим знаходить збіг і забирає запит. Якщо файл public/api/users існує, твій хендлер не запуститься.

Помилка 2: Відносний шлях без __dirname

js
// Неправильно - працює в dev, ламається на деплої app.use(express.static('public')); // Правильно - завжди резолвиться від директорії скрипта app.use(express.static(path.join(__dirname, 'public')));

Просто 'public' резолвиться від process.cwd(), який залежить від того, де ти запускаєш процес. __dirname - це завжди директорія файлу з кодом.

Помилка 3: Немає maxAge в продакшені

js
// Неправильно - кожен користувач перезавантажує 2MB JS-бандл при кожному візиті app.use(express.static('public')); // Правильно app.use(express.static('public', { maxAge: '1y' })); // для хешованих файлів

Помилка 4: Роздача завантажень користувачів як статики

js
// Неправильно - будь-хто бачить будь-який завантажений файл app.use('/uploads', express.static('uploads')); // Правильно - спочатку перевіряємо авторизацію app.get('/uploads/:id', authMiddleware, (req, res) => { res.sendFile(path.join(__dirname, 'uploads', req.params.id)); });

Це одна з найпоширеніших дірок у безпеці Express-додатків. Без перевірки прав кожен файл у папці стає публічним.

Де зустрічається в реальних проектах

  • Create React App: app.use(express.static(path.join(__dirname, 'build'))) - стандартне налаштування для продакшену.
  • Next.js standalone: власний статичний хендлер з immutable-кешуванням для /_next/static.
  • Express + Socket.io: спочатку статика, потім монтування socket-хендлера, щоб не було конфліктів URL.
  • До 10k запитів на секунду: Express static достатньо. Більше - ставь nginx або CDN (CloudFront, Cloudflare) перед додатком.

На низькотрафікових внутрішніх інструментах я бачив команди, які взагалі не підключали nginx. Воно працює. Але в продакшені з реальними користувачами nginx або CDN економлять і гроші, і час відповіді.

Follow-up питання

Q: Яка різниця в продуктивності між express.static і власним хендлером через fs.readFile?
A: Static використовує fs.createReadStream, який передає дані у відповідь без буферизації. fs.readFile завантажує весь файл у пам'ять, що призводить до аварій на великих файлах і збільшення RAM при навантаженні.

Q: Як працює ETag у процесі перевірки кешу?
A: Сервер надсилає ETag: "abc123" (MD5-хеш файлу). Браузер зберігає його і при наступному запиті надсилає If-None-Match: "abc123". Express перевіряє, чи збігається хеш, і якщо так - відповідає 304 без тіла відповіді.

Q: Чому compression() має стояти перед express.static()?
A: Compression обгортає вихідний стрім відповіді. Express.static генерує цей стрім. Якщо static запускається першим, байти вже відправлені до того, як compression може їх перехопити.

Q: Як інвалідувати кеш браузера після деплою?
A: Використовуй хешування імен файлів. Webpack і Vite додають хеш вмісту до імені (наприклад, main.abc123.js). Ти встановлюєш maxAge: '1y', і коли файл змінюється, його ім'я теж змінюється. Старий кеш стає недійсним автоматично.

Q: Як підключити SPA-роутинг (React Router) разом з express.static?
A: Спочатку роздаємо папку з білдом, потім перехоплюємо всі решта роути і надсилаємо index.html. Client-side роутер обробляє все інше.

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

Приклади

Базовий: роздача публічної папки з кешуванням

js
const express = require('express'); const path = require('path'); const app = express(); app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h', // браузер кешує на 1 годину })); app.listen(3000); // Перший GET /logo.png → 200, Cache-Control: public, max-age=3600 // Другий GET /logo.png → 304 (з кешу браузера, 0 байт надіслано)

Файли в public/ відображаються прямо на URL. public/logo.png стає /logo.png, public/js/app.js стає /js/app.js. Жодного коду роутів не потрібно.

Середній рівень: продакшен-білд React зі стисненням

js
const express = require('express'); const path = require('path'); const compression = require('compression'); const app = express(); // Gzip для відповідей більше 1kb app.use(compression()); // Роздаємо React-білд - хешовані імена дозволяють кешувати на рік app.use(express.static(path.join(__dirname, 'build'), { maxAge: '1y', etag: true, lastModified: true, })); // Fallback для SPA - React Router обробляє навігацію на клієнті 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 // Перезавантаження сторінки → 304, 0 байт завантажено

Стиснення зменшує типовий JS-бандл з 45kb приблизно до 12kb. У поєднанні з річним кешем повторні відвідувачі отримують майже миттєве завантаження.

Просунутий рівень: різні заголовки кешу для різних типів файлів

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) => { // Хешовані JS/CSS: immutable, кешувати назавжди if (filePath.match(/\.(js|css)$/)) { res.set('Cache-Control', 'public, max-age=31536000, immutable'); } // Зображення: кешувати на 30 днів if (filePath.match(/\.(png|jpg|svg|webp)$/)) { res.set('Cache-Control', 'public, max-age=2592000'); } } })); // API-роути не зачіпаються app.get('/api/users', (req, res) => { res.json({ users: [] }); }); app.listen(3000);

setHeaders дає контроль над кожним типом файлу без окремих роутів. Хешовані файли отримують immutable, і браузер більше не перевіряє їх зовсім. Зображення кешуються коротше, бо можуть змінитися без зміни імені файлу.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?