Сжатие данных в Node.js: zlib, gzip и brotli

✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026

Содержание

Сжатие в Node не требует зависимостей: модуль node:zlib встроен в платформу и умеет три формата. Вопрос не в том, как вызвать функцию, — вопрос в том, какой формат выбрать и какой ценой.

Три формата и когда какой

import { gzip, brotliCompress, deflate } from 'node:zlib';
import { promisify } from 'node:util';

const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
Формат Кто понимает Когда брать
gzip все браузеры, gunzip, любой прокси универсальный выбор, динамика
brotli все современные браузеры статика, сжатая заранее
deflate тот же алгоритм, но без заголовков gzip почти никогда

Deflate — источник путаницы. Алгоритм внутри у него и у gzip один и тот же; различаются только обёртка и контрольная сумма. Gzip добавляет около 18 байт метаданных, зато его понимает командная строка. Отдельно выбирать deflate почти никогда не нужно.

Замер на реальных данных

Синтетика вроде «одна строка × 100000» показывает сжатие в 300 раз и не говорит вообще ни о чём. Возьмём реальные данные — исходник этой самой статьи, 19 947 байт текста с кодом:

const real = await readFile('node-js-promise.md');

const gz = await gzipAsync(real);
const br = await brotliAsync(real);
const df = await deflateAsync(real);
исходник: статья блога, 19947 байт
gzip   : 6903 байт | 2.89x | 2.5 мс
brotli : 5909 байт | 3.38x | 19.5 мс
deflate: 6891 байт | 2.89x | 1.2 мс
экономия brotli против gzip: 14.4%
Размер после сжатия статьи на 19 947 байт
Данные таблицей
Показатель Размер
Исходник 19947 байт
gzip 6903 байт
deflate 6891 байт
brotli 5909 байт

Источник: собственный замер, Node.js v22.23.1

Что здесь видно:

  • Реальное сжатие текста — около 3x, а не 300x. Это честный ориентир для HTML, JSON и логов.
  • Brotli выигрывает у gzip 14.4% — заметно, но не радикально.
  • Deflate и gzip дали почти одно и то же: разница 12 байт из 6900 — те самые заголовки.

Главная ловушка brotli: качество по умолчанию

Вот цифра, ради которой стоило замерять. У gzip уровень сжатия по умолчанию средний, а у brotli — максимальный, 11. Отсюда те самые 19.5 мс.

Поменяем качество:

import { constants } from 'node:zlib';

await brotliAsync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
brotli q=4  : 7197 байт | 2.77x | 0.9 мс
brotli q=11 : 5909 байт | 3.38x | 19.4 мс  <- дефолт
gzip        : 6903 байт | 2.89x | 2.5 мс
Время сжатия той же статьи
Данные таблицей
Показатель Время
brotli q=4 0.9 мс
deflate 1.2 мс
gzip 2.5 мс
brotli q=11 (дефолт) 19.4 мс

Источник: собственный замер, Node.js v22.23.1

8x
Brotli по умолчанию медленнее gzip
19.5 мс против 2.5 мс
Источник: собственный замер, Node v22.23.1
14.4%
Выигрыш brotli в размере
5909 против 6903 байт
Источник: собственный замер, Node v22.23.1
~3x
Реальное сжатие текста
а не 300x, как на синтетике
Источник: собственный замер, Node v22.23.1

Практический вывод: brotli с настройками по умолчанию нельзя ставить на динамические ответы. 19 мс процессорного времени на каждый ответ — это не «чуть медленнее», это заблокированный событийный цикл, потому что сжатие целиком считает CPU. При сотне запросов в секунду сервер просто ляжет.

Где brotli уместен: статика, сжатая один раз при сборке. Там 19 мс платятся однажды, а 14% экономии получают все посетители.

Сжатие маленьких данных вредит

const tiny = Buffer.from('ok');
const tinyGz = await gzipAsync(tiny);
исходник: 2 байт -> gzip: 22 байт
стало БОЛЬШЕ в 11x — это заголовки формата

У любого формата есть служебный заголовок. Пока данных мало, он весит больше самих данных. Отсюда правило: не сжимайте ничего меньше примерно килобайта — именно поэтому у nginx есть настройка gzip_min_length.

То же касается уже сжатого: JPEG, PNG, MP4, архивы. Прогонять их через gzip — потратить процессор и не выиграть ничего.

Правильный конвейер: pipeline

Для файлов сжатие делают потоком — тогда файл любого размера не попадает в память целиком:

import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';

await pipeline(
  createReadStream('big.log'),
  createGzip(),
  createWriteStream('big.log.gz')
);

Именно pipeline, а не pipe: при ошибке он закроет всю цепочку и отдаст исключение. Подробнее — в разборе потоков.

Проверка целостности

Полезная привычка при работе со сжатием — убедиться, что распаковка возвращает исходник байт в байт:

const back = await gunzipAsync(gz);
console.log('вернул байт в байт:', back.equals(real));
gunzip вернул байт в байт  : true
brotli вернул байт в байт  : true

Оба формата — сжатие без потерь, и Buffer.equals это подтверждает. Если вдруг не совпало — ищите, где данные прошли через строку: конвертация в текст и обратно ломает байты, не являющиеся валидным UTF-8.

Нужно ли сжимать в Node вообще

Честный ответ: чаще всего нет. Если перед приложением стоит nginx, Caddy или облачный прокси, сжатие лучше отдать им. Причина не в удобстве, а в архитектуре: сжатие целиком нагружает процессор, а у Node один поток на ваш код — каждый сжатый ответ отнимает время у всех остальных.

Модуль zlib нужен, когда:

  • вы сжимаете файлы, а не ответы: архивы, выгрузки, бэкапы;
  • готовите статику при сборке — вот здесь brotli q=11 на своём месте;
  • работаете с уже сжатыми данными: читаете .gz-логи, распаковываете чужие архивы.

Что было на этой странице в 2018 году

Первая версия вышла 8 июля 2018 года. Пример был такой:

const zlib = require('zlib');
const fs = require('fs');

const gzip = zlib.createGzip();
const inp = fs.createReadStream('test.png');
var out = fs.createWriteStream('test.png.gz');

inp.pipe(gzip).pipe(out);

Три приметы времени: require, var и цепочка .pipe().pipe() — та самая, что теряет ошибки и оставляет потоки открытыми. pipeline в Node тогда ещё не завезли.

Но интереснее то, чего в статье нет: ни слова про brotli. И это не упущение автора — brotli в Node на тот момент не существовало, он появился в платформе позже. Статья разбирала gzip и deflate просто потому, что других вариантов не было. Сегодня выбор из двух форматов сжатия — и половина этого разбора посвящена тому, чего в 2018-м не могло быть в принципе.

И ещё одна деталь: пример сжимал test.png. Сегодня это как раз пример того, как делать не надо — PNG уже сжат, и gzip поверх него не даст ничего.


Смежные темы: потоки и pipeline — как сжимать файлы любого размера; HTTP-сервер — где сжатие отдают прокси; модуль fs — чтение и запись файлов. Полный список — в справочнике по Node.js.

Частые вопросы

Что выбрать для HTTP-ответов — gzip или brotli?
Brotli, если контент статичный и сжат заранее. Для динамики — gzip либо brotli с качеством 4–5: на нашем замере brotli q=11 занял 19.5 мс против 2.5 мс у gzip, и на каждый запрос это неприемлемо.
Нужно ли сжимать в Node, если впереди nginx или Cloudflare?
Нет. Сжатие — работа с высокой нагрузкой на CPU, и отдавать её прокси правильнее: он это делает эффективнее и не занимает ваш единственный поток.
Чем deflate отличается от gzip?
Алгоритм внутри тот же (DEFLATE), различаются заголовки и контрольная сумма. Gzip добавляет ~18 байт метаданных и понятен утилите gunzip; чистый deflate — нет. На нашем замере разница в размере составила 12 байт из ~6900.
Почему сжатый файл получился больше исходного?
Данных слишком мало. У любого формата есть заголовок: 2 байта через gzip превратились в 22. Сжимать что-то меньше примерно килобайта бессмысленно.
Можно ли сжимать уже сжатое — JPEG, PNG, видео?
Смысла нет: они уже сжаты своими алгоритмами. Вы потратите процессор и получите примерно тот же размер, иногда чуть больше.