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 мс
Тиков 10-мс таймера во время чтения файла 117 МБ
Данные таблицей
Показатель Сработало тиков
readFileSync 0 тиков
readFile (await) 7 тиков

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

Ноль. Таймер, который за 269 мс обязан был сработать примерно 26 раз, не сработал ни разу: событийный цикл был заморожен целиком. Асинхронный вариант тикал штатно, максимальная пауза — 11 мс при заданных 10.

Что это значит на живом сервере: пока один запрос читает файл синхронно, все остальные клиенты ждут. Не «медленнее обрабатываются» — вообще не обрабатываются.

0
Тиков таймера за 269 мс
во время readFileSync
Источник: собственный замер, Node v22.23.1
7
Тиков за то же чтение
во время await readFile
Источник: собственный замер, Node v22.23.1
1
Поток для вашего кода
занят — значит занят весь сервер

Оговорка, чтобы не соврать цифрой: разницу во времени (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` не блокирует, если поток всё равно один?
Само чтение с диска выполняет пул потоков libuv вне вашего JavaScript. Код получает управление обратно сразу, а результат приходит, когда данные готовы.
Насколько `readFileSync` быстрее, раз у него нет накладных расходов?
На единичном чтении разница в пределах погрешности. Но «быстрее для одного запроса» ценой «сервер не отвечает остальным» — плохой размен.
Конфиг нужен синхронно, а в API только промисы. Что делать?
Использовать readFileSync — это ровно тот случай, ради которого он и оставлен. Либо await на верхнем уровне ESM-модуля.