Как стать автором
Обновить
672.06
OTUS
Цифровые навыки от ведущих экспертов

Почему твой await fetch тормозит — и как это исправить

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров7.1K

Привет, Хабр!

Сегодня рассмотрим, почему безобидная строчка await fetch() неожиданно превращается в тормоз, где именно она зарывает драгоценные миллисекунды — и что можно сделать с этим.

Холодные TCP-соединения: 200 мс на ровном месте

Симптом: первый запрос к API стабильно дольше остальных, а при бурсте t₉₅ скачет в космос.

Каждый 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

Даже при всех оптимизациях t₉₅ выше, чем хотелось бы.

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 плюс обработка на бэкенде.

Фикс

  1. Упростите запрос: GET или POST с Content-Type: text/plain (если это ок) и без кастомных заголовков.

  2. Кэшируйте preflight на стороне сервера:

    // Express + cors
    app.options('*', cors({
      origin: true,
      credentials: true,
      maxAge: 86_400,      // 24 ч браузер не будет слать OPTIONS
    }));
  3. Схлопните домены — перенесите API на подпуть (/api) того же origin, чтобы CORS исчез вовсе.

HOL-блокировка в HTTP/2: один жирный поток тормозит всех

На HTTP/2-бэкенде всплески t₉₉ происходят ровно во время крупных загрузок/отдач (файлы > 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, но он всё равно синхронен.

Фикс

  1. Стримовый 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 ГБ данных.

  2. Пишите в BSON/MsgPack, минуя stringify.

  3. Отказывайтесь от лишнего 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:

Теги:
Хабы:
+77
Комментарии8

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS