Работа с файлами в Node.js: типичные ошибки
Синхронный fs в обработчике запроса, чтение гигабайтного лога в память, гонки при записи и молчаливое проглатывание ошибок — разбираем, где именно рушится работа с файлами в Node.js и чем это лечить.

Сервис отдавал файлы нормально, пока их не стало сто одновременных. Потом отдача встала: event loop заблокировала одна строка fs.readFileSync в обработчике маршрута. Классика — код работал на тесте с одним пользователем и сломался под нагрузкой. Ниже — ошибки, на которые натыкаются даже те, кто пишет на Node не первый год. Всё актуально для Node.js 20 LTS и 22.x; API fs/promises в этих версиях стабилен.
Синхронные вызовы там, где обслуживаются запросы
У модуля fs три лица: колбэки (fs.readFile), промисы (fs/promises) и синхронные версии с суффиксом Sync. Суффикс Sync блокирует единственный поток, в котором крутится ваш JavaScript. Пока файл читается, сервер не обрабатывает никакие другие соединения.
// Плохо: блокирует event loop на время чтения
app.get('/report', (req, res) => {
const data = fs.readFileSync('./big-report.json', 'utf8');
res.type('json').send(data);
});
// Хорошо: чтение уходит в пул потоков libuv, event loop свободен
const fsp = require('node:fs/promises');
app.get('/report', async (req, res, next) => {
try {
const data = await fsp.readFile('./big-report.json', 'utf8');
res.type('json').send(data);
} catch (err) {
next(err);
}
});
Синхронные вызовы уместны ровно в одном месте: на старте приложения, когда вы читаете конфиг до того, как сервер начал принимать соединения. В обработчике запроса, в таймере, в обработчике события — никогда.
Одна строка с Sync в горячем пути превращает многозадачный сервер в однопользовательский. Найти её потом по симптому «периодически всё подвисает» — отдельное удовольствие на пару часов.
Чтение большого файла целиком в память
readFile загружает весь файл в один буфер. Для конфига в килобайт это нормально. Для лога на 800 МБ — прямой путь к JavaScript heap out of memory, потому что строка в V8 упирается в лимит heap, а буфер держится в памяти целиком, пока вы с ним работаете.
Когда файл нужно обработать по частям — читайте потоком. Стрим отдаёт данные кусками (chunk), память под один chunk освобождается, пока вы читаете следующий.
const fs = require('node:fs');
const readline = require('node:readline');
// Считаем строки с ошибками в большом логе, не загружая его целиком
async function countErrors(path) {
const stream = fs.createReadStream(path, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let errors = 0;
for await (const line of rl) {
if (line.includes('ERROR')) errors++;
}
return errors;
}
Если файл нужно просто отдать клиенту — не читайте его в переменную вообще. Используйте pipeline, который сам управляет обратным давлением (backpressure) и корректно закрывает потоки при ошибке.
const { pipeline } = require('node:stream/promises');
app.get('/download', async (req, res, next) => {
try {
await pipeline(fs.createReadStream('./big-report.json'), res);
} catch (err) {
next(err);
}
});
Молчаливое проглатывание ошибок
Файл могли удалить, к нему могли отобрать права, диск мог кончиться. Node сообщает об этом кодами в поле err.code: ENOENT — нет файла, EACCES — нет прав, EMFILE — исчерпан лимит открытых дескрипторов, ENOSPC — нет места. Код в этих полях — стабильный контракт, на него можно завязывать логику; текст сообщения между версиями меняться может.
const fsp = require('node:fs/promises');
async function loadConfig(path) {
try {
return JSON.parse(await fsp.readFile(path, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') {
// Нет файла — это ожидаемо, возвращаем дефолт
return {};
}
// Всё остальное — реальная проблема, не глотаем
throw err;
}
}
Антипаттерн — пустой catch {} или catch (e) { console.log(e) } без реакции. В проде такой код скрывает от вас, что бэкапы перестали писаться неделю назад.
Проверка существования перед действием
Соблазн написать «сначала проверю, есть ли файл, потом прочитаю» приводит к гонке (TOCTOU — time-of-check to time-of-use): между проверкой и действием файл может исчезнуть или появиться. fs.exists к тому же объявлен устаревшим.
Правильный подход — не проверять, а пытаться и обрабатывать ошибку:
- Нужно прочитать файл? Читайте и ловите
ENOENT, а не вызывайтеaccessзаранее. - Нужно создать файл только если его нет? Открывайте с флагом
'wx'— он падает сEEXIST, если файл существует, атомарно. - Нужна папка?
fsp.mkdir(dir, { recursive: true })не падает, если папка уже есть.
// Создать файл, только если его ещё нет — без гонки
try {
await fsp.writeFile('./lock', String(process.pid), { flag: 'wx' });
} catch (err) {
if (err.code === 'EEXIST') {
console.error('Процесс уже запущен');
process.exit(1);
}
throw err;
}
Пути: конкатенация строк и разделители
Склеивание путей через './data/' + name ломается на Windows (там разделитель \\) и открывает дыру для обхода каталога, если name приходит от пользователя и содержит ../../etc/passwd.
const path = require('node:path');
const UPLOAD_DIR = path.resolve('./uploads');
function safePath(userName) {
// Отсекаем директории из пользовательского имени
const clean = path.basename(userName);
const full = path.join(UPLOAD_DIR, clean);
// Контрольная проверка: результат внутри разрешённой папки
if (!full.startsWith(UPLOAD_DIR + path.sep)) {
throw new Error('Недопустимый путь');
}
return full;
}
Утечка дескрипторов
Если открыли файл через fsp.open() и работаете с дескриптором вручную, его надо закрыть — даже когда посреди работы вылетело исключение. Незакрытые дескрипторы копятся, и рано или поздно вы упираетесь в EMFILE: too many open files. Спасает finally либо FileHandle с автозакрытием через using (доступно в Node 22 с поддержкой Explicit Resource Management).
let handle;
try {
handle = await fsp.open('./data.bin', 'r');
const buf = Buffer.alloc(1024);
await handle.read(buf, 0, 1024, 0);
// работа с buf
} finally {
await handle?.close(); // закрываем даже при ошибке чтения
}
Сравнение подходов к чтению
| Подход | Когда | Память | Риск |
|---|---|---|---|
readFileSync | только старт приложения, конфиг | весь файл | блокирует event loop |
fsp.readFile | небольшие файлы в обработчике | весь файл | OOM на больших файлах |
createReadStream + readline | построчная обработка логов, CSV | один chunk | сложнее код |
pipeline в ответ | отдача файла клиенту | один chunk | минимальный |
Что мерить и делать дальше
Проверьте прод по трём точкам. Первое — grep по коду на Sync в обработчиках; всё, что не на старте, переписать на промисы. Второе — следите за process.memoryUsage().heapUsed на маршрутах, которые читают файлы: рост под нагрузкой означает чтение целиком там, где нужен стрим. Третье — держите под рукой лимит открытых дескрипторов (ulimit -n на Linux) и логируйте EMFILE отдельным алертом.
Отдельная тема — атомарная запись через временный файл и rename, чтобы читатель не поймал полузаписанный файл; здесь мы её не разворачиваем. Следующий шаг для чтения — раздел fs в официальной документации Node.js и раздел stream: там описано поведение backpressure и полный список кодов ошибок.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Node.js Documentation — File system (fs), Node.js Documentation — Stream
Частые вопросы
У меня Node 18, а не 20 или 22 — примеры сработают?
Почему readFileSync вообще существует, если он такой опасный?
Я читаю файл потоком, но память всё равно растёт. В чём дело?
Как отличить «файла нет» от «нет прав» в обработке ошибки?
Стоит ли вообще пользоваться колбэчной версией fs, или только промисами?
Что делать, если под нагрузкой ловлю EMFILE?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.