Потоки в Node.js: Stream, pipeline и почему не readFile

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

Содержание

Потоки — та часть Node, которую чаще всего обходят стороной: readFile короче и понятнее. Пока файлы маленькие, это и правда так. Проблема появляется, когда «маленький файл» однажды оказывается на 2 ГБ, а процесс падает с out of memory.

Разберём, что именно даёт поток, на замерах.

Что такое поток простыми словами

Поток — это способ обработать данные по частям, не загружая их в память целиком.

Аналогия: чтобы перелить воду из бочки в бочку, не нужен резервуар размером с бочку — достаточно шланга. readFile — это резервуар: сначала налить всё, потом отдать. Поток — шланг: пока одна порция уходит, следующая уже течёт.

Замер: readFile против потока

Возьмём файл на 183 МБ и прочитаем его двумя способами, измеряя реальную память процесса (RSS) с принудительной сборкой мусора перед замером.

import { readFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';

// вариант 1: целиком в память
const whole = await readFile(big, 'utf8');

// вариант 2: потоком, по кускам
let bytes = 0, chunks = 0, maxChunk = 0;
for await (const chunk of createReadStream(big)) {
  bytes += chunk.length;
  chunks += 1;
  if (chunk.length > maxChunk) maxChunk = chunk.length;
}
размер файла: 183 МБ
readFile: RSS 83 -> 252 МБ  (+169 МБ)
  строка в памяти целиком: 192000000 символов
поток   : RSS 252 -> 288 МБ  (+35 МБ)
  прочитано байт: 192000000 | кусков: 2930 | самый большой кусок: 65536 байт
Прирост памяти процесса (RSS) при чтении файла 183 МБ
Данные таблицей
Показатель Прирост RSS
readFile (целиком) 169 МБ
createReadStream (потоком) 35 МБ

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

Читаем результат честно:

  • readFile вырос почти на размер файла — иначе и быть не может, всё содержимое лежит в одной строке.
  • У потока живых данных никогда не больше 64 КБ: это видно по maxChunk = 65536. Прирост RSS в 35 МБ — это не данные, а память, которую аллокатор забрал у ОС и не вернул сразу.
  • Ключевая разница не в 169 против 35, а в масштабировании: у потока потребление не зависит от размера файла. На файле в 10 ГБ кусок останется 64 КБ, а readFile просто упадёт.
64 КБ
Максимальный кусок потока
независимо от размера файла
Источник: собственный замер, Node v22.23.1
+169 МБ
Прирост RSS у readFile
файл 183 МБ целиком в памяти
Источник: собственный замер, Node v22.23.1
2930
Кусков за проход
файл 183 МБ
Источник: собственный замер, Node v22.23.1

Четыре типа потоков

Тип Что делает Примеры
Readable отдаёт данные createReadStream, тело HTTP-запроса
Writable принимает данные createWriteStream, HTTP-ответ
Duplex и то, и другое TCP-сокет
Transform преобразует по пути createGzip, шифрование

Смысл в том, что они соединяются в конвейер: источник → преобразование → приёмник. Все четыре — наследники EventEmitter, поэтому у них есть on('data'), on('error') и on('end'); pipeline просто избавляет от необходимости подписываться руками.

pipeline вместо pipe

Забудьте pipe() — берите pipeline(). Причина в обработке ошибок: если в цепочке a.pipe(b).pipe(c) упадёт b, потоки a и c останутся открытыми, а ошибка потеряется. Это классическая утечка дескрипторов.

pipeline закрывает все потоки цепочки при любом сбое и отдаёт ошибку. А версия из node:stream/promises ещё и работает с await:

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

await pipeline(
  createReadStream('big.txt'),
  createGzip(),
  createWriteStream('big.txt.gz')
);
исходный : 192000000 байт
сжатый   : 558826 байт
сжатие   : 343.6x

Оговорка про 343x: наш тестовый файл — одна и та же строка, повторённая тысячи раз, а gzip живёт за счёт повторов. На реальных логах ждите 5–10x, на JSON — примерно столько же. Цифра здесь показывает не «какой хороший gzip», а то, что весь конвейер отработал, ни разу не подняв в память все 183 МБ.

Ошибка в конвейере превращается в обычное исключение:

try {
  await pipeline(
    Readable.from(['a']),
    new Writable({ write(c, e, cb) { cb(new Error('писать некуда')); } })
  );
} catch (err) {
  console.log('pipeline бросил:', err.message);
}
pipeline бросил: писать некуда

Свои Readable и Writable

Готовые потоки — не единственный вариант, свои создаются просто. Readable.from() делает поток из любого итерируемого:

import { Readable, Writable } from 'node:stream';

const src = Readable.from(['раз', 'два', 'три']);

const collected = [];
const dst = new Writable({
  write(chunk, encoding, callback) {
    collected.push(chunk.toString());
    callback();               // сигнал «готов к следующему куску»
  }
});

await pipeline(src, dst);
console.log('собрано:', collected.join(','));
собрано: раз,два,три

Вызов callback() здесь обязателен: именно им поток сообщает, что готов принимать дальше. Забыли — конвейер встанет навсегда и никакой ошибки не будет.

Backpressure: зачем всё это нужно

Главное, что даёт поток помимо памяти, — обратное давление. Если приёмник медленнее источника (диск быстрее сети), поток сам притормаживает чтение, пока приёмник разбирает очередь.

Именно это ломается, когда пишут «вручную»:

// ❌ игнорирует backpressure: читаем как можем, пишем куда придётся
readable.on('data', (chunk) => writable.write(chunk));

// ✅ pipeline сам согласует скорости
await pipeline(readable, writable);

В первом варианте при медленном приёмнике данные копятся во внутреннем буфере — и память растёт ровно так же, как при readFile, только неожиданно.

highWaterMark: размер куска настраивается

64 КБ — это highWaterMark по умолчанию, а не константа. Его можно менять:

createReadStream(path, { highWaterMark: 256 * 1024 });  // куски по 256 КБ

Размен простой: больше кусок — меньше системных вызовов, но выше расход памяти. На быстром SSD и крупных файлах увеличение даёт выигрыш; на мелких файлах — только лишний расход. Трогать значение стоит, когда вы упёрлись в производительность и замерили, а не «на всякий случай»: дефолт выбран разумно.

Отдельно живёт объектный режим. Обычные потоки гоняют байты, но если поставить objectMode: true, через поток пойдут любые значения — объекты, строки, что угодно:

const src = Readable.from([{ id: 1 }, { id: 2 }]);   // objectMode включается сам

Здесь highWaterMark считается уже не в байтах, а в штуках объектов (по умолчанию 16). Это рабочий способ построить конвейер обработки записей: читать CSV построчно, преобразовывать и писать в базу, не держа в памяти весь файл.

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

Первая версия вышла 8 июля 2018 года — тогда потоки описывали через pipe(), потому что stream/promises ещё не существовало: он появился только в Node 15, осенью 2020-го. То есть совет «берите pipeline» физически не мог быть дан.

Что изменилось за эти годы:

2018 2026
a.pipe(b).pipe(c) await pipeline(a, b, c)
ошибки ловятся .on('error') на каждом потоке одна try/catch вокруг pipeline
stream.on('data') для чтения вручную for await (const chunk of stream)
потоки закрывать самому pipeline закрывает всю цепочку сам

Сам механизм при этом не поменялся ни на байт: те же четыре типа, тот же backpressure, тот же highWaterMark в 64 КБ. Поменялся только способ их соединять — и именно он снял класс ошибок с утечкой дескрипторов.

Когда поток не нужен

Честно: в большинстве задач. Конфиг, JSON на пару сотен килобайт, шаблон письма — читайте readFile, код будет короче и понятнее, а разницы не будет никакой.

Поток оправдан, когда:

  • размер данных заранее неизвестен или потенциально велик (загрузки пользователей, логи, выгрузки);
  • данные можно обрабатывать по мере поступления, не дожидаясь конца (разбор CSV построчно);
  • вы соединяете источник и приёмник напрямую — например, отдаёте файл в HTTP-ответ;
  • вывод внешней программы нужно читать на лету, не копя в памяти, — так работает spawn из child_process.

Ещё один ориентир — жёсткий предел: readFile с кодировкой физически не прочитает файл больше ~512 МБ. У потока такого потолка нет вообще.

Остальные разборы платформы собраны в справочнике по Node.js.

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

Почему кусок именно 64 КБ?
Это highWaterMark по умолчанию для файловых потоков. Значение настраивается: createReadStream(path, { highWaterMark: 256 * 1024 }). Больше — меньше системных вызовов, но выше расход памяти.
`for await` по потоку — это нормально?
Да, потоки реализуют асинхронный итератор, и это самый читаемый способ пройти поток вручную. Если данные просто перекладываются из одного места в другое — pipeline лучше: он ещё и backpressure держит.
Чем `pipeline` из `stream/promises` отличается от обычного?
Только интерфейсом: промис вместо колбэка. Логика та же, но работает try/catch и не нужен лишний уровень вложенности.
Как отдать файл в HTTP-ответ?
await pipeline(createReadStream(path), res) — ответ сервера сам по себе Writable. Подробнее про сервер — в статье про HTTP-сервер на Node.js.