Работа с файлами в Node.js: разбор на живом примере
Разбираем чтение и запись файлов в Node.js на примере обработки лога: чем отличаются fs.readFileSync, промисы и потоки, где каждый подход ломается и как выбрать.

Задача простая на вид: есть лог доступа Nginx на 2 ГБ, нужно посчитать, сколько запросов вернуло 500-е коды, и записать выборку таких строк в отдельный файл. Первый порыв — fs.readFileSync, разбить по \n, отфильтровать. На тестовом файле в пару мегабайт работает мгновенно. На проде процесс падает с RangeError: Cannot create a string longer than 0x1fffffe8 characters или съедает всю память контейнера. Разбираем, почему так, и какие три подхода к файлам в Node.js есть, у каждого своя цена.
Всё ниже — для Node.js 20.x LTS и новее. Модуль fs встроенный, ставить ничего не нужно. Примеры используют ESM (import), для CommonJS замените на const { ... } = require('fs').
Три способа читать файл и чем они платят
В fs живут три семейства функций для одной и той же операции. Разница не в синтаксисе, а в том, сколько памяти и когда они занимают.
1. Синхронное чтение целиком
readFileSync блокирует event loop, пока весь файл не окажется в памяти буфером или строкой. Пока идёт чтение 2-гигабайтного файла, ваш сервер не отвечает ни на один запрос.
import { readFileSync } from 'node:fs';
// весь файл целиком попадает в память
const content = readFileSync('access.log', 'utf8');
const lines = content.split('\n');
const errors = lines.filter((line) => line.includes(' 500 '));
console.log(`Строк с 500: ${errors.length}`);Здесь два самостоятельных провала: строка в V8 ограничена примерно 512 МБ (тот самый 0x1fffffe8), а до этого предела вы уже держите в куче копию всего файла плюс массив строк. Синхронное чтение оправдано только для мелких конфигов на старте приложения, когда блокировка никому не мешает.
2. Асинхронное чтение через промисы
Версия из fs/promises не блокирует event loop — пока диск отдаёт данные, Node.js обслуживает другие запросы. Но файл всё равно читается целиком в память.
import { readFile } from 'node:fs/promises';
async function countErrors(path) {
const content = await readFile(path, 'utf8');
const errors = content
.split('\n')
.filter((line) => line.includes(' 500 '));
return errors.length;
}
console.log(await countErrors('access.log'));Это лечит блокировку, но не лечит память. Тот же 2-гигабайтный файл упрётся в тот же лимит строки. Промисы — правильный дефолт для файлов, которые заведомо влезают в память: JSON-конфиг на несколько мегабайт, шаблон письма, загруженная картинка перед обработкой.
3. Потоки
Поток читает файл кусками (по умолчанию 64 КБ) и отдаёт их по мере поступления. В памяти в каждый момент — только один кусок, а не весь файл. Для 2 ГБ лога это единственный вменяемый вариант.
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
async function extractErrors(inputPath, outputPath) {
const input = createReadStream(inputPath, { encoding: 'utf8' });
const output = createWriteStream(outputPath);
// readline режет поток по строкам, не собирая файл целиком
const rl = createInterface({ input, crlfDelay: Infinity });
let total = 0;
for await (const line of rl) {
if (line.includes(' 500 ')) {
total += 1;
// если write вернул false, ждать 'drain' — см. раздел про backpressure
output.write(line + '\n');
}
}
// дождаться, пока всё запишется и файл закроется
await new Promise((resolve, reject) => {
output.end(resolve);
output.on('error', reject);
});
return total;
}
console.log(await extractErrors('access.log', 'errors.log'));Память тут держится в пределах десятков мегабайт независимо от того, 2 ГБ файл или 20. Цена — код сложнее, и появляется backpressure, о котором ниже.
Правило, которое экономит инциденты: если размер файла задаёт пользователь или внешняя система — считайте его неограниченным и берите поток. Целиком в память читайте только то, чей потолок вы контролируете сами.
Что выбрать под конкретную задачу
| Подход | Память | Блокирует event loop | Когда брать |
|---|---|---|---|
readFileSync | весь файл | да | мелкие конфиги на старте процесса |
fs/promises readFile | весь файл | нет | файлы с известным потолком размера |
createReadStream | один кусок (~64 КБ) | нет | большие или файлы неизвестного размера |
Критерий один и внятный: знаете ли вы верхнюю границу размера файла. Знаете и она мала — читайте целиком, код проще. Не знаете или она большая — поток.
Backpressure: где ломается наивная запись потока
В примере выше есть комментарий про drain, и это не формальность. output.write() возвращает false, когда внутренний буфер записи переполнен — диск не успевает за темпом, с которым вы подаёте строки. Если игнорировать возврат и продолжать писать, строки копятся в памяти, и вы приходите к тому же переполнению, от которого убегали.
Ручная обработка выглядит громоздко, поэтому для связки «читать поток → преобразовать → писать поток» правильнее использовать pipeline из stream/promises: он сам управляет backpressure и корректно закрывает потоки при ошибке.
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
const filterErrors = new Transform({
transform(chunk, encoding, callback) {
// chunk может обрывать строку посередине — для точной построчной
// обработки надёжнее readline; здесь показан принцип Transform
const text = chunk.toString();
const errors = text
.split('\n')
.filter((line) => line.includes(' 500 '))
.join('\n');
callback(null, errors ? errors + '\n' : '');
},
});
await pipeline(
createReadStream('access.log'),
filterErrors,
createWriteStream('errors.log'),
);
console.log('Готово');У этого варианта своя граничная проблема: кусок от createReadStream может разрезать строку пополам, и фильтр по строкам даст неверный результат на стыке кусков. Для точной построчной работы возвращайтесь к readline, а Transform берите там, где данные не привязаны к границам строк (бинарь, посимвольная трансформация).
Ещё две ловушки на границах
- Кодировка. Без указания
encodingreadFileиcreateReadStreamвозвращаютBuffer, а не строку. Если ждёте текст и забыли'utf8'— получите<Buffer 47 45 54>вместо строк. Обратная ошибка опаснее: читать бинарный файл (архив, картинку) как'utf8'портит данные необратимо. - Относительные пути.
readFile('config.json')ищет файл относительно рабочей директории процесса (process.cwd()), а не относительно файла с кодом. Запустите скрипт из другой папки — получитеENOENT. Стройте путь от расположения модуля черезimport.meta.urlиfileURLToPath, а не полагайтесь наcwd.
Что делать дальше
Первым делом на любой файловой операции с внешним источником поставьте потолок и обработку ENOENT и EACCES — эти ошибки прилетают в проде постоянно, а необработанный промис роняет процесс. Дальше стоит померить реальный расход памяти: запустите обработку под --max-old-space-size с заниженным лимитом и смотрите, падает ли процесс — так вы поймаете чтение-целиком до того, как это сделает прод. Инвалидация кеша прочитанных файлов и слежение за изменениями через fs.watch — отдельная тема, здесь мы разобрали только чтение и запись.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Node.js File system (fs) — официальная документация, Node.js Stream — Backpressure and pipeline
Частые вопросы
У меня Node.js 18, примеры с fs/promises и pipeline из stream/promises сработают?
Почему процесс падает с RangeError про длину строки, хотя памяти в контейнере ещё полно?
Читаю файл через readline, но получаю пустой результат — что не так?
Стоит ли вообще использовать синхронные Sync-функции?
В проде обработка большого файла иногда съедает память, хотя я использую поток — почему?
Как правильно строить путь к файлу, чтобы не ловить ENOENT при запуске из другой папки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.