Привет, Хабр!
Сегодня рассмотрим, почему безобидная строчка await fetch()
неожиданно превращается в тормоз, где именно она зарывает драгоценные миллисекунды — и что можно сделать с этим.
Холодные TCP-соединения: 200 мс на ровном месте
Симптом: первый запрос к API стабильно дольше остальных, а при бурсте скачет в космос.
Каждый fetch()
в лоб открывает новый сокет: 1×DNS, 1×TCP-handshake, 1×TLS. Средний RTT в Европе ~50 мс, умножаем — получаем сотни лишних миллисекунд.
Бенчмарки показывают 3-кратный выигрыш при reuse 1000 соединений против «одно соединение — один запрос».
Фикс
// Node 22+, global fetch уже есть
import { Agent } from 'undici';
const api = new Agent({
keepAliveTimeout: 30_000, // держим сокет 30 с
connections: 100, // пул
});
const res = await fetch('https://api.payments.local/v1/orders', { dispatcher: api });
В браузере — заголовок Connection: keep-alive
плюс переход на HTTP/2/3. На фронте можно добавить <link rel="preconnect" href="https://api.payments.local">
, чтобы сдвинуть handshake до реального клика.
Undici держит keep-alive нативно и даёт почти 10× прирост к core-HTTP-клиенту.
DNS + TLS: два скрытых дракончика
Даже с Keep-Alive первый запрос к другому домену снова длинный.
DNS-lookup — блокирует JS-поток в браузере (до 100 мс на мобильных сетях). TLS-handshake — три round-trip вместо одного у TCP.
Лучше явно кешировать DNS-запросы и увеличивать maxSockets
, если у Вас десятки доменных имён.
Фикс
# nginx.conf
resolver 9.9.9.9 valid=300s;
// Node
const agent = new Agent({ connect: { lookup: dnsCache.lookup }});
Альтернатива — QUIC/HTTP-3 с 0-RTT, но помните о странном поведении прокси — результаты замеров HTTP/2 vs HTTP/1.1 порой непредсказуемы.
response.json() блокирует event-loop
Сервер отдаёт 5-10 МБ JSON — и Ваш API-роут зависает, CPU прыгает до 100 %.
Response.prototype.json()
читает весь поток в память и только потом вызывает JSON.parse
, монополизируя главное ядро.
Фикс: потоковый парсинг
import { parse } from 'stream-json';
import { chain } from 'stream-chain';
const pipeline = chain([
response.body, // ReadableStream из fetch()
parse(),
({ key, value }) => { /* ...обрабатываем фрагменты... */ },
]);
await finished(pipeline);
Отказ от WebStreams в пользу нативных Node-стримов ускоряет чтение на 60–90 %.
Вес ответа: сжатие и форматы
Скорость сети нормальная, но загрузка всё равно долгая, особенно на мобильном 4G.
Так бывает, когда API отдаёт «голый» JSON = 2×-3× лишних байт. Сервер не умеет Brotli, а клиент не просит. Отдаёте изображения в PNG вместо WebP/AVIF.
Фикс
// фронт
await fetch(url, {
headers: { 'Accept-Encoding': 'br, gzip' }
});
// backend (Fastify)
fastify.register(require('@fastify/compress'), {
brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }
});
Brotli на текстовых payload даёт до 25 % экономии трафика сверх Gzip.
Await-в-цикле
Есть 100 параллельных задач, но общее время выполнения ≈ 100 × одна задача.
Классика:
for (const id of ids) {
const res = await fetch(`/api/item/${id}`);
items.push(await res.json());
}
Вы линеаризуете все запросы.
Фикс
const concurrency = 10; // бережём бэкенд
const queue = [...ids];
const results = [];
await Promise.all(
Array.from({ length: concurrency }, async function worker() {
while (queue.length) {
const id = queue.pop();
const res = await fetch(`/api/item/${id}`);
results.push(await res.json());
}
})
);
Медленный клиент: fetch vs undici.request
Даже при всех оптимизацияхвыше, чем хотелось бы.
Node-fetch реализован как «обёртка поверх обёртки»: web-совместимость > лишний GC, абстракции. Matt Pocock измерил: undici.request
≈ 2–3× быстрее built-in fetch
.
Фикс
import { request } from 'undici';
const { body } = await request('https://inventory.local/items', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'content-type': 'application/json' },
});
const data = await body.json(); // всё ещё удобно
Если Ваше приложение — это в основном HTTP-вызовы, переход на undici.request
даст 3× ускорение.
CORS-preflight
Каждый первый вызов к стороннему API стабильно отнимает +150–250 мс. В Perf-таб «царит» лишний запрос OPTIONS
, а следом уже идёт Ваш реальный GET/POST
.
Браузер посылает preflight-запрос при нес-simple методах/заголовках (любое Content-Type
кроме application/x-www-form-urlencoded
, кастомные headers, credentials: true
и т.п.). Пока сервер не ответит 200 OK, основное обращение не стартует — двойной RTT плюс обработка на бэкенде.
Фикс
Упростите запрос:
GET
илиPOST
сContent-Type: text/plain
(если это ок) и без кастомных заголовков.Кэшируйте preflight на стороне сервера:
// Express + cors app.options('*', cors({ origin: true, credentials: true, maxAge: 86_400, // 24 ч браузер не будет слать OPTIONS }));
Схлопните домены — перенесите API на подпуть (
/api
) того же origin, чтобы CORS исчез вовсе.
HOL-блокировка в HTTP/2: один жирный поток тормозит всех
На HTTP/2-бэкенде всплески происходят ровно во время крупных загрузок/отдач (файлы > 5 МБ). Логи пусты, CPU — норм, сеть — норм, но фронт фризит.
При потере пакета TCP-соединение приостанавливает всё, что идёт после головы. В HTTP/2 все стримы мультиплексируются в один TCP-поток, поэтому падение одного пакета на file-upload стопорит параллельные API-вызовы.
Фикс
Разнесите крупные и мелкие запросы: держите отдельный агент/домен/порт под толстые аплоуды.
const bulkAgent = new Agent({ maxStreamsPerConnection: 1 }); // по одному потоку
await fetch(largeUrl, { dispatcher: bulkAgent, body: bigFile });
Включите HTTP/3 (QUIC) — он работает по UDP, каждый поток независим. Тюньте приоритизацию: http2_max_concurrent_streams
, priority
-хинты или отдельные WAF-правила, чтобы пакет-потеря не клала всё соединение.
На тестовом стенде переключение больших upload-ручек на HTTP/3 дало –45 % к медиане и –79 % к p99.
JSON.stringify перед отправкой
При массовом POST /bulk
наблюдаете 100 % CPU, а сетевой трафик стартует поздно: процесс кипит ещё до фактического fetch()
.
Запрос не улетит, пока Вы не сериализуете payload. Классическая связка
await fetch(url, { body: JSON.stringify(hugeObject) });
одним махом конвертирует десятки мегабайт в строку, блокируя event-loop. А если Вы ещё и клонируете объект, задав body: {...data}
, время удваивается. В Node 24/Chromium 122 уже доступен structuredClone()
, который быстрее deep-copy, но он всё равно синхронен.
Фикс
Стримовый multipart/NDJSON-upload — сериализуем кусочно:
import { Readable } from 'node:stream'; const enc = new TextEncoder(); const stream = Readable.from( bigArray.map(x => enc.encode(JSON.stringify(x) + '\n')) ); await fetch('/bulk/ingest', { method: 'POST', body: stream });
Подход держит память стабильно < 50 МБ даже на 1 ГБ данных.
Пишите в BSON/MsgPack, минуя stringify.
Отказывайтесь от лишнего deep-copy — вместо
const safe = structuredClone(huge);
чаще достаточно простой валидации через Schemas.
Итак
Измеряйте. npx bench-rest
, autocannon
, браузерный PerformanceTab. Чините по слоям: сеть > протокол > клиент > сериализация > алгоритм. Не бойтесь альтернатив: GraphQL-over-HTTP/2, gRPC-web, msgpack вместо JSON. Автоматизируйте. Добавьте Artillery в CI, ставьте алерты на p95>200 мс.
Всех, кто ищет прямой путь к полезным и практичным знаниям, приглашаем на серию открытых уроков, которые пройдут в рамках курса Fullstack Developer: