Fetch и работа с API из браузера: разбор на живом примере
Fetch не бросает исключение на 404 и 500, зависшие запросы копятся в вкладке, а гонки перезаписывают свежие данные старыми. Разбираем, где очевидный код ломается и как писать надёжно.

Вы пишете fetch(url).then(r => r.json()), сервер отвечает 500, а ваш код спокойно идёт в блок обработки успеха и падает уже на попытке прочитать поле несуществующего объекта. Это самая частая ловушка Fetch: он считает успехом сам факт получения ответа, а не его статус. Ниже — разбор четырёх мест, где наивный код работает в демо и разваливается в проде: проверка статуса, отмена запроса, гонки и повторы. Примеры на нативном Fetch, доступном во всех актуальных браузерах и в Node 18+.
Задача и наивное решение
Есть поле поиска. На каждый ввод символа уходит запрос к API, ответ рисуется списком. Очевидный вариант выглядит так:
// работает в демо, ломается в реальности
async function search(query) {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
renderResults(data.items);
}
Здесь минимум три проблемы, каждая проявится не сразу. Разберём по одной.
Проблема 1: Fetch не считает 4xx и 5xx ошибкой
Промис от fetch() отклоняется только при сетевом сбое: DNS не разрешился, соединение оборвалось, сработал CORS. HTTP-ответ 404 или 500 — это для Fetch успешно доставленный ответ. Проверять статус нужно вручную через response.ok (true для кодов 200–299).
async function getJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
// читаем тело, чтобы не потерять текст ошибки от сервера
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
return response.json();
}
Второй нюанс — response.json() сам бросит SyntaxError, если тело пустое или это HTML со страницей ошибки от прокси. Поэтому в проде тело ответа с ошибкой полезно читать как текст, а не как JSON.
Fetch резолвится успехом на любом HTTP-статусе. Отсутствие if (!response.ok) — это не стилистика, а баг, который вы увидите в первый же день, когда бэкенд отдаст 503.
Проблема 2: запрос некому отменить
В примере с поиском пользователь печатает быстро. Пока летит запрос по «reac», уходит запрос по «react». Первый вам уже не нужен, но он висит, занимает лимит одновременных соединений (браузеры держат порядка 6 на домен) и в итоге может прийти после второго, перезаписав актуальный результат устаревшим. Отменять запросы умеет AbortController.
let controller = null;
async function search(query) {
// отменяем предыдущий запрос, если он ещё летит
if (controller) controller.abort();
controller = new AbortController();
try {
const data = await getJson(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
renderResults(data.items);
} catch (err) {
// отменённый запрос — не ошибка, это ожидаемое поведение
if (err.name === 'AbortError') return;
showError(err.message);
}
}
Обратите внимание: при отмене промис отклоняется с err.name === 'AbortError'. Если не отфильтровать этот случай, вы будете показывать пользователю «ошибку» каждый раз, когда он просто печатает дальше.
Тайм-аут через тот же механизм
У Fetch нет встроенного тайм-аута — запрос может висеть, пока сервер молчит. В современных средах есть AbortSignal.timeout(ms), который сам отменяет запрос по истечении времени. Проверьте поддержку для ваших целевых браузеров; при сомнении соберите сигнал вручную через setTimeout и controller.abort().
// AbortSignal.timeout — проверьте поддержку в целевых браузерах
const data = await getJson('/api/data', {
signal: AbortSignal.timeout(5000) // отмена через 5 секунд
});
Проблема 3: гонки ответов
Даже с отменой предыдущего запроса гонка возможна, если вы отменять не стали или запросы идут к разным ресурсам. Классика: запрос А ушёл раньше, но сервер ответил на него медленнее, чем на более поздний запрос Б. Б отрисовался, потом пришёл А и затёр экран старыми данными.
Отмена (проблема 2) закрывает случай «одно поле поиска». Если запросы независимы, помогает пометка каждого запроса и проверка на актуальность результата перед отрисовкой:
let latestRequestId = 0;
async function loadTab(tabName) {
const requestId = ++latestRequestId;
const data = await getJson(`/api/tab/${tabName}`);
// если за время запроса пользователь переключил вкладку — не рисуем
if (requestId !== latestRequestId) return;
renderTab(data);
}
Проблема 4: повторы и когда они опасны
Сеть на мобильном рвётся, и один повтор часто спасает запрос. Но повторять можно не всё. Повтор идемпотентного запроса1 — GET, PUT, DELETE — безопасен: результат тот же. Повтор POST, который создаёт заказ, при неудачном стечении создаст два заказа: первый ответ потерялся в сети, но на сервере запрос прошёл.
async function getWithRetry(url, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await getJson(url);
} catch (err) {
const isLast = attempt === retries;
// 4xx повторять бессмысленно — ответ не изменится
const isClientError = /HTTP 4\d\d/.test(err.message);
if (isLast || isClientError) throw err;
// растущая пауза перед повтором
await new Promise(r => setTimeout(r, 300 * (attempt + 1)));
}
}
}
Повторять только GET, не повторять 4xx (кроме 408 и 429), делать растущую паузу между попытками — это минимум, который отличает рабочий повтор от усилителя нагрузки на падающий сервер.
Сводка: что и когда применять
| Механизм | Закрывает | Цена |
|---|---|---|
response.ok | Молчаливую обработку 4xx/5xx как успеха | Пара строк, нужны всегда |
AbortController | Висящие запросы, гонку в поиске и автодополнении | Хранение ссылки на контроллер |
Тайм-аут (AbortSignal.timeout) | Запрос, висящий, пока сервер молчит | Проверка поддержки в старых браузерах |
| ID запроса | Гонку ответов независимых запросов | Счётчик и проверка перед рендером |
| Повтор с бэкоффом | Разовые сетевые сбои на GET | Риск дублей на не-идемпотентных методах |
Типичные ошибки
- Забыли
await response.json()— работаете с промисом вместо данных, получаетеundefinedв полях. - Вызвали
response.json()дважды — тело потока читается один раз, второй вызов бросит ошибку. Сохраните результат в переменную. - Не экранировали параметр запроса —
encodeURIComponentобязателен для любых пользовательских значений в URL. - Ловите ошибку и показываете её, не отфильтровав
AbortError— пользователь видит «ошибку» при каждом нажатии клавиши. - Для отправки cookie на кросс-доменный API забыли
credentials: 'include'— сервер не видит сессию.
Что делать дальше
Соберите обёртку getJson с проверкой response.ok, тайм-аутом и повтором для GET в одном месте проекта — и вызывайте только её, а не голый fetch по коду. Дальше смотрите в сторону обработки заголовков Retry-After при 429 и на клиентские библиотеки (TanStack Query, SWR), которые берут на себя отмену, кеш и дедупликацию запросов. Ретраи и кеширование ответов — отдельная большая тема, здесь мы разобрали только базовую надёжность одного запроса.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: MDN — Using the Fetch API, MDN — AbortController
Частые вопросы
Почему fetch не выбрасывает ошибку на статус 404?
AbortSignal.timeout не работает — что делать?
В чём разница между отменой запроса и проверкой ID запроса?
Можно ли безопасно повторять POST-запрос при сбое?
Почему второй вызов response.json() падает?
Как отправить cookie на API другого домена?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.