Ваш debounce вас обманывает — и вот почему

Обложка: Ваш debounce вас обманывает — и вот почему

Debounce — один из тех паттернов, которые фронтенд-разработчик узнаёт в самом начале карьеры и использует всю оставшуюся жизнь.

По сути debounce делает одну простую вещь: собирает серию вызовов и превращает их в один вызов после паузы. Отлично подходит для «шумных» UI-событий.

Самый типичный пример — автодополнение в поиске. Но тот же паттерн работает для обработки resize, scroll, live-валидации, фильтров и хуков аналитики.

Классическая реализация выглядит так:

			function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const search = debounce(async (q) => {
  const res = await fetch(`/api/search?q=${q}`);
  const data = await res.json();
  render(data);
}, 300);
		

Выглядит дисциплинированно. Ощущается эффективно. Быстро доезжает до прода.

И вот тут начинается обман.

Проблема не в самом 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(), когда нужно отменить запрос.

			let controller;

const debouncedFetch = debounce(async (q) => {
  if (!q) return;

  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const response = await fetch(
      `/api/echo?q=${encodeURIComponent(q)}`,
      { signal: controller.signal }
    );
    const data = await response.json();
    render(data);
  } catch (err) {
    if (err.name === "AbortError") return;
    // обработка реальных ошибок
  }
}, 300);
		

Что изменилось:

  • Перед каждым запросом мы отменяем предыдущий через abort() и создаём новый контроллер
  • В блоке catch проверяем, является ли ошибка AbortError — это ожидаемое поведение, а не реальный сбой

Результат: в обычном потоке только последний запрос из серии нажатий доходит до конца. Предыдущие отменяются на уровне сети, а не просто игнорируются после получения ответа.

Проблема 2 — сетевые ошибки

Сеть непредсказуема не только по задержкам, но и по надёжности. Иногда запрос, который мог бы пройти при повторной попытке, просто падает. Причины: кратковременная перегрузка сервера, пики нагрузки, таймауты базы данных.

fetch не бросает исключение при HTTP-ошибках

Это одна из главных ловушек нативного fetch: он отклоняет промис только при сетевых сбоях (нет соединения). Коды 4xx и 5xx — это «успешные» ответы с точки зрения fetch. Если сервер вернёт 500, ваш код радостно вызовет response.json() и получит undefined вместо данных.

Исправляем проверкой response.ok:

			const response = await fetch(
  `/api/echo?q=${encodeURIComponent(q)}`,
  { signal: controller.signal }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
		

Теперь 500-я ошибка выбрасывает исключение до того, как мы пытаемся разобрать тело ответа. Блок catch обработает её корректно.

Повторные попытки с экспоненциальной задержкой

Но простая проверка — это только начало. В реальном приложении стоит добавить автоматические повторные попытки для транзиентных ошибок. Если запрос упал из-за временной проблемы, лучше попробовать ещё раз с нарастающей задержкой, чем сразу показывать ошибку пользователю.

Писать логику повторов вручную — это циклы, счётчики попыток, тайминги, и всё это должно корректно работать с отменой. Нетривиально и неинтересно. Воспользуемся библиотекой @fetchkit/ffetch — это тонкая обёртка над fetch, которая решает именно эту задачу.

Есть и альтернативы: ky, axios или собственная обёртка. ffetch выбран за совместимый с fetch API и корректную работу с AbortController при повторных попытках.

			import { createClient } from "@fetchkit/ffetch";

const api = createClient({
  retries: 3,
  shouldRetry: (ctx) => ctx.response?.status >= 500,
  throwOnHttpError: true,
});
		

Что нам это даёт:

  • retries: 3 — при 500-й ошибке библиотека повторяет запрос до 3 раз
  • shouldRetry — повторяем только при 5xx; всё остальное (сетевая ошибка, отмена) пробрасывается сразу
  • throwOnHttpError: true — автоматически бросает исключение на HTTP-ошибки, не нужна ручная проверка response.ok
  • Задержка между повторами учитывает AbortController — если abort() вызван во время ожидания, повтор немедленно прекращается

Последний пункт особенно важен. Без этого отмена запроса в середине серии повторов убила бы текущий fetch, но оставила бы таймер — и следующая попытка сразу бы упала с AbortError.

Полное решение

Собираем всё вместе: debounce для UI-сглаживания, AbortController для отмены устаревших запросов, ffetch для повторных попыток и автоматической обработки HTTP-ошибок.

			import { createClient } from "@fetchkit/ffetch";

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const api = createClient({
  retries: 3,
  shouldRetry: (ctx) => ctx.response?.status >= 500,
  throwOnHttpError: true,
});

let controller;

const debouncedFetch = debounce(async (q) => {
  if (!q) return;

  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const response = await api(
      `/api/echo?q=${encodeURIComponent(q)}`,
      { signal: controller.signal }
    );
    const data = await response.json();
    render(data);
  } catch (err) {
    if (err.name === "AbortError") return;
    showError(err.message);
  }
}, 300);
		

Каждый слой отвечает за своё: 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).