readFile или readFileSync: чем синхронное чтение вредит серверу
✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026
Содержание
У модуля fs почти каждая функция существует в двух вариантах: readFile и readFileSync, writeFile и writeFileSync. Синхронный короче и не требует ни await, ни колбэка — и именно поэтому его тянут туда, где он ломает сервер.
Разберёмся, что происходит на самом деле, — на замере.
В чём вообще разница
Асинхронная версия отдаёт работу платформе и освобождает поток; синхронная выполняет её прямо здесь, не давая процессу заниматься ничем другим.
Node выполняет ваш JavaScript в одном потоке. Пока этот поток занят, не обрабатывается ничего: ни другие HTTP-запросы, ни таймеры, ни колбэки. readFileSync занимает поток на всё время чтения с диска.
Обе функции живут в модуле fs и делают одно и то же — разница только в том, кто ждёт диск: платформа или ваш поток.
Замер: сколько стоит блокировка
Заведём таймер, который тикает каждые 10 мс, и посчитаем, сколько раз он сработает во время чтения файла на 117 МБ.
let ticks = 0;
const timer = setInterval(() => ticks++, 10);
readFileSync(big, 'utf8'); // синхронно
// ...либо...
await readFile(big, 'utf8'); // асинхронно
clearInterval(timer);
console.log('тиков таймера:', ticks);
размер файла: 117 МБ
readFileSync : заняло 269 мс | тиков таймера: 0 | худшая пауза: 0 мс
readFile (await): заняло 72 мс | тиков таймера: 7 | худшая пауза: 11 мс
Данные таблицей
| Показатель | Сработало тиков |
|---|---|
| readFileSync | 0 тиков |
| readFile (await) | 7 тиков |
Источник: собственный замер, Node.js v22.23.1
Ноль. Таймер, который за 269 мс обязан был сработать примерно 26 раз, не сработал ни разу: событийный цикл был заморожен целиком. Асинхронный вариант тикал штатно, максимальная пауза — 11 мс при заданных 10.
Что это значит на живом сервере: пока один запрос читает файл синхронно, все остальные клиенты ждут. Не «медленнее обрабатываются» — вообще не обрабатываются.
Оговорка, чтобы не соврать цифрой: разницу во времени (269 против 72 мс) в заслугу асинхронности записывать не стоит — там играют роль и прогрев дискового кэша, и порядок запусков. Достоверный вывод замера ровно один: синхронное чтение останавливает цикл, асинхронное — нет.
Три способа написать одно и то же
В модуле fs живут три API для одной операции:
import { readFile } from 'node:fs/promises'; // промисы — так пишут сегодня
import { readFileSync } from 'node:fs'; // синхронно
import { readFile as readFileCb } from 'node:fs'; // колбэк — legacy
console.log('promises :', await readFile(f, 'utf8'));
console.log('sync :', readFileSync(f, 'utf8'));
readFileCb(f, 'utf8', (err, data) => console.log('callback :', data));
promises : привет
sync : привет
callback : привет
Результат одинаковый, разница — в цене. Колбэчная версия не блокирует, но вложенность колбэков и ручная проверка err в каждом — ровно то, ради чего придумали промисы. По умолчанию берите node:fs/promises.
Когда Sync всё-таки можно
Синхронные версии — не зло, у них есть законная область:
| Можно | Нельзя |
|---|---|
загрузка конфига при старте, до listen() |
внутри обработчика HTTP-запроса |
| CLI-скрипты, миграции, build-скрипты | в обработчике события на «горячем» пути |
| одноразовое чтение, когда никто не ждёт | в цикле по списку файлов |
Логика простая: пока сервер ещё не принимает запросы, блокировать некого. Прочитать конфиг через readFileSync на старте — до вызова listen() — нормально и даже удобнее: не нужно тащить await на верхний уровень модуля.
Здесь же кроется и типичная ошибка масштаба: Sync в цикле по списку файлов останавливает цикл на каждой итерации. Если файлов много, читайте их параллельно через Promise.all — там же разобрано, почему await внутри цикла обходится в пять раз дороже.
Кодировка: строка или Buffer
Оба метода без второго аргумента возвращают Buffer — сырые байты, а не текст:
const buf = await readFile(f); // без encoding
const str = await readFile(f, 'utf8'); // с encoding
без encoding : Buffer | длина 12 байт
с utf8 : string | длина 6 символов
Файл содержал слово «привет» — шесть символов, но двенадцать байт: кириллица в UTF-8 занимает по два байта на букву. Отсюда классическая ошибка — считать длину текста по размеру файла.
Buffer нужен, когда данные не текст (картинка, архив) или когда вы просто перекладываете байты. Для текста указывайте кодировку.
У readFile есть жёсткий потолок
Это не вопрос «хватит ли памяти» — это предел движка. Строка в V8 не может быть длиннее фиксированного значения, и readFile с кодировкой в него упирается.
import { constants } from 'node:buffer';
console.log('MAX_STRING_LENGTH:', constants.MAX_STRING_LENGTH, 'символов');
'x'.repeat(constants.MAX_STRING_LENGTH + 1);
MAX_STRING_LENGTH : 536870888 символов (~0.50 ГБ)
превышение лимита : RangeError: Invalid string length
Полгигабайта — потолок. Файл больше не прочитается через readFile(path, 'utf8') никогда: ни на машине с 8 ГБ, ни на машине с 512 ГБ. Вы получите RangeError, а не «медленно, но работает».
Отсюда практическое следствие, о котором стоит подумать заранее: если данные растут (логи, выгрузки, пользовательские загрузки), readFile — это мина с таймером. Он будет прекрасно работать на тестовых 10 МБ и упадёт в проде через полгода. Единственный вариант, у которого потолка нет, — поток: он читает по 64 КБ и не зависит от размера файла вообще.
Без кодировки (в Buffer) лимит выше, но упирается уже в реальную память процесса — и там же живёт --max-old-space-size.
Не проверяйте существование файла заранее
Частый паттерн — existsSync перед чтением. Он не только лишний, но и содержит гонку: между проверкой и чтением файл может исчезнуть.
console.log('existsSync говорит:', existsSync(race));
await unlink(race); // файл удалили прямо сейчас
await readFile(race, 'utf8'); // ...и чтение всё равно упало
existsSync говорит: true
чтение упало : ENOENT
Правильный способ — попытаться прочитать и поймать ошибку:
try {
return await readFile(path, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return null; // файла нет — штатная ситуация
throw err; // всё остальное — настоящая ошибка
}
Проверять нужно err.code, а не текст сообщения: коды (ENOENT, EACCES, EISDIR) стабильны, а формулировки меняются между версиями.
Смежные темы: модуль fs целиком; потоки — единственный вариант без потолка по размеру; HTTP-сервер — где синхронное чтение вреднее всего. Полный список — в справочнике по Node.js.
Частые вопросы
`readFile` использует потоки?
Почему `readFile` не блокирует, если поток всё равно один?
Насколько `readFileSync` быстрее, раз у него нет накладных расходов?
Конфиг нужен синхронно, а в API только промисы. Что делать?
readFileSync — это ровно тот случай, ради которого он и оставлен. Либо await на верхнем уровне ESM-модуля.