Ваш debounce вас обманывает — и вот почему
Debounce — один из тех паттернов, которые фронтенд-разработчик узнаёт в самом начале карьеры и использует всю оставшуюся жизнь.
По сути debounce делает одну простую вещь: собирает серию вызовов и превращает их в один вызов после паузы. Отлично подходит для «шумных» UI-событий.
Самый типичный пример — автодополнение в поиске. Но тот же паттерн работает для обработки resize, scroll, live-валидации, фильтров и хуков аналитики.
Классическая реализация выглядит так:
Выглядит дисциплинированно. Ощущается эффективно. Быстро доезжает до прода.
И вот тут начинается обман.
Проблема не в самом debounce. Проблема в связке «debounce + fetch», когда в уравнение входит реальная сеть.
Debounce создаёт ощущение, что запросы «под контролем». Но он не контролирует жизненный цикл запроса: порядок ответов, отмену устаревших запросов, поведение при ошибках.
Именно поэтому в продакшене debounce «врёт»: UI выглядит плавно, а сетевой слой по-прежнему хрупкий.
В этой статье мы оставим debounce для того, в чём он хорош (сглаживание UI), и укрепим сетевой слой отменой запросов, повторными попытками и корректной обработкой ошибок.
Ключевые выводы:
— Debounce — это паттерн UI, а не паттерн работы с сетью
— Он гарантирует только одно: «я не буду вызывать функцию слишком часто»
— Порядок ответов, отмена устаревших запросов и обработка ошибок — это то, что вам придётся решать отдельно
—AbortControllerотменяет устаревшие запросы на уровне сети
— Повторные попытки с экспоненциальной задержкой спасают от транзиентных ошибок сервера
Проблема 1 — гонка запросов (race conditions)
На локальной машине всё летает. Но в продакшене сеть непредсказуема: запросы могут приходить с разной задержкой, и нет никакой гарантии, что ответы придут в том же порядке, в каком были отправлены.
Представьте: пользователь печатает 12345678. Debounce пропускает запросы для 1234567 и 12345678. Ответ на 1234567 задерживается на сервере и приходит после ответа на 12345678. UI обновляется последним пришедшим ответом — и показывает устаревшие данные.
Это классическая гонка запросов, и debounce сам по себе её не предотвращает.
Решение — AbortController
Нам нужно гарантировать, что обрабатывается только ответ на последний запрос, а все предыдущие — отменяются. AbortController — это браузерный API, который позволяет отменять fetch-запросы. Создаём контроллер, передаём его signal в fetch, и вызываем abort(), когда нужно отменить запрос.
Что изменилось:
- Перед каждым запросом мы отменяем предыдущий через
abort()и создаём новый контроллер - В блоке
catchпроверяем, является ли ошибкаAbortError— это ожидаемое поведение, а не реальный сбой
Результат: в обычном потоке только последний запрос из серии нажатий доходит до конца. Предыдущие отменяются на уровне сети, а не просто игнорируются после получения ответа.
Проблема 2 — сетевые ошибки
Сеть непредсказуема не только по задержкам, но и по надёжности. Иногда запрос, который мог бы пройти при повторной попытке, просто падает. Причины: кратковременная перегрузка сервера, пики нагрузки, таймауты базы данных.
fetch не бросает исключение при HTTP-ошибках
Это одна из главных ловушек нативного fetch: он отклоняет промис только при сетевых сбоях (нет соединения). Коды 4xx и 5xx — это «успешные» ответы с точки зрения fetch. Если сервер вернёт 500, ваш код радостно вызовет response.json() и получит undefined вместо данных.
Исправляем проверкой response.ok:
Теперь 500-я ошибка выбрасывает исключение до того, как мы пытаемся разобрать тело ответа. Блок catch обработает её корректно.
Повторные попытки с экспоненциальной задержкой
Но простая проверка — это только начало. В реальном приложении стоит добавить автоматические повторные попытки для транзиентных ошибок. Если запрос упал из-за временной проблемы, лучше попробовать ещё раз с нарастающей задержкой, чем сразу показывать ошибку пользователю.
Писать логику повторов вручную — это циклы, счётчики попыток, тайминги, и всё это должно корректно работать с отменой. Нетривиально и неинтересно. Воспользуемся библиотекой @fetchkit/ffetch — это тонкая обёртка над fetch, которая решает именно эту задачу.
Есть и альтернативы: ky, axios или собственная обёртка. ffetch выбран за совместимый с fetch API и корректную работу с AbortController при повторных попытках.
Что нам это даёт:
retries: 3— при 500-й ошибке библиотека повторяет запрос до 3 разshouldRetry— повторяем только при 5xx; всё остальное (сетевая ошибка, отмена) пробрасывается сразуthrowOnHttpError: true— автоматически бросает исключение на HTTP-ошибки, не нужна ручная проверкаresponse.ok- Задержка между повторами учитывает
AbortController— еслиabort()вызван во время ожидания, повтор немедленно прекращается
Последний пункт особенно важен. Без этого отмена запроса в середине серии повторов убила бы текущий fetch, но оставила бы таймер — и следующая попытка сразу бы упала с AbortError.
Полное решение
Собираем всё вместе: debounce для UI-сглаживания, AbortController для отмены устаревших запросов, ffetch для повторных попыток и автоматической обработки HTTP-ошибок.
Каждый слой отвечает за своё: debounce снижает частоту вызовов, AbortController гарантирует, что обрабатывается только актуальный запрос, а ffetch добавляет устойчивость к транзиентным сбоям.
Частые вопросы
Зачем AbortController, если debounce и так снижает количество запросов?
Debounce снижает частоту вызовов, но не контролирует, что происходит с уже отправленными запросами. Если два запроса ушли один за другим, более ранний может вернуться позже — и перезаписать актуальные данные. AbortController отменяет устаревший запрос на уровне сети, а не просто игнорирует ответ.
Можно ли обойтись без сторонней библиотеки для повторов?
Да, можно написать retry-логику вручную. Но это циклы, счётчики, экспоненциальная задержка и корректная обработка отмены. В продакшен-коде проще использовать готовое решение — ffetch, ky или axios — чтобы не изобретать велосипед и не допускать ошибок в edge-кейсах.
Работает ли этот подход с React / Vue / Angular?
Да. AbortController и retry-логика — это чистый JavaScript, независимый от фреймворка. В React, например, AbortController часто используется в useEffect для отмены запросов при размонтировании компонента. Принцип тот же: debounce для UI, отмена и повторы для сетевого слоя.
Выводы
Debounce — не проблема. Проблема — считать его полным решением для управления сетевыми запросами, когда он контролирует только одно измерение: частоту вызовов.
Debounce — это паттерн UI, а не паттерн работы с сетью. Чтобы построить надёжное приложение, нужно дополнить его управлением жизненным циклом запросов:
- Отмена устаревших запросов через
AbortController - Повторные попытки с экспоненциальной задержкой для транзиентных сбоев
- Корректная обработка HTTP-ошибок (проверка
response.okили автоматический проброс через библиотеку)
Тогда UI будет не только отзывчивым, но и точным — даже при непредсказуемых сетевых условиях.
Адаптированный перевод статьи Your Debounce Is Lying to You Габора Кооша (Gabor Koos).