Девять шаблонов обещаний для надежного асинхронного JavaScript в производственной среде
Изучите практические шаблоны Promise — параллельные запросы, таймауты, повторные попытки, ограничения на одновременную работу и отмену — для создания надежного асинхронного JavaScript промышленного уровня.
Вы постоянно используете Promises, но несколько менее известных паттернов могут превратить запутанный асинхронный код в нечто предсказуемое и легкое для понимания.
Большинство людей знакомятся с Promises через пример вроде этого:
fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));
Для простых скриптов этого действительно достаточно.
Проблемы начинаются, когда вы переходите к приложениям производственного уровня.
Вдруг вам нужно выполнять несколько запросов одновременно, а не по очереди.
Вам нужен способ отмены работы, которая больше не актуальна.
Вам нужны попытки повтора при сбое запроса.
Вам нужно продолжать работу даже тогда, когда удается выполнить только часть операции.
Вам нужно предотвратить случайное двойное выполнение одного и того же API-запроса.
И иногда вам нужно ограничить количество одновременно выполняемых асинхронных операций, чтобы ваш бэкенд не перегружался всем сразу.
Именно здесь Promises перестают быть темой для начинающих и превращаются в настоящий инструмент проектирования.
Ниже приведены девять шаблонов, которые стоит иметь в своем арсенале при написании современного JavaScript.
1. Отправка независимых запросов параллельно
Один из самых простых способов улучшения асинхронного кода — это также один из самых легких к упущению.
Предположим, что вашей странице нужны три вещи: данные пользователя, уведомления и аналитика.
Типичная первая попытка выглядит так:
const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.
Каждый await блокирует выполнение до завершения предыдущего вызова, поэтому все вызовы выполняются последовательно.
Если предположить, что каждый вызов занимает примерно 500 мс, то такая последовательная обработка может привести к ожиданию около 1,5 секунды.
Если ни один из этих вызовов на самом деле не зависит от других, нет причин ждать.
Именно для этого и существует Promise.all():
const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);
Теперь все три запроса отправляются одновременно, вместо того чтобы идти по очереди.
Для панелей управления или любых экранов, загружающих несколько независимых источников данных, это может значительно сократить время загрузки.
Важный нюанс
Promise.all() срабатывает быстро: как только хотя бы один из обещаний становится невыполнимым, вся группа тоже становится невыполнимой.
Это подходит, когда каждый запрос является обязательным, но если некоторые данные являются необязательными, потребуется более гибкий подход.
2. Используйте Promise.allSettled(), когда допускаются частичные сбои
Представьте панель управления администратора, которая отображает:
- Доход
- Пользователи
- Уведомления
- Состояние системы
Если сервис уведомлений случайно выйдет из строя, должен ли весь экран стать пустым?
Обычно нет — потеря одного раздела не должна повлиять на остальную часть страницы.
Promise.allSettled() решает именно эту проблему.
const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});
Одна неудачная вызовы больше не уничтожают результаты, которые были получены успешно рядом с ней.
Такой подход особенно полезен на панелях управления, экранах аналитики и инструментах мониторинга, где показ частичных данных лучше, чем отсутствие данных вовсе.
3. Добавление таймаута к Promise
Рано или поздно вы столкнетесь с такой ситуацией: что произойдёт, если запрос просто никогда не вернётся?
Без защитных мер интерфейс может застрять на неопределённое время.
Вы можете создать небольшой, повторно используемый обёрточный класс, который обеспечивает соблюдение таймаута:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}
Затем используйте его там, где вы отправляете запросы:
try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}
Если вызов не завершится в течение пяти секунд, обёрнутый Promise автоматически отклонится.
Это гораздо лучший вариант, чем заставлять пользователей смотреть на индикатор загрузки, который никогда не завершается.
4. Повторная попытка выполнения неудачных операций
Сети иногда прерывают соединения.
Серверы работают с перебоями.
API сторонних разработчиков иногда ведут себя некорректно.
Все это не обязательно означает, что пользователь должен сразу видеть сообщение об ошибке.
Для временных сбоев небольшая функция для повторных попыток может сильно помочь.
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
Вы вызываете её так:
const data = await retry(
() => fetch("/api/data"),
3
);
Теперь операция получает несколько дополнительных шансов, прежде чем будет считаться настоящей ошибкой.
Но не стоит слепо повторять все операции.
Статус 500 часто указывает на временную проблему с сервером.
Статус 401 Unauthorized, с другой стороны, не будет устранен путем отправки того же запроса ещё три раза.
Хорошая логика повторных попыток позволяет отличать ошибки, которые стоит повторять, от тех, которые — нет.
5. Добавление задержек между повторными попытками
Попытка повторной отправки запроса сразу после его неудачи не всегда является разумным решением.
Представьте сервер, который и так работает с трудом под нагрузкой.
Если тысячи клиентов попытаются отправить запросы заново одновременно, это создаст еще большую нагрузку на уже перегруженную систему.
Для решения этой проблемы подходит функция задержки:
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
Ее можно просто встроить в цикл повторных попыток:
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}
В производственных системах часто используется еще более сложный метод — экспоненциальное откладывание попыток, при котором они выполняются с увеличивающимися интервалами:
1 second
2 seconds
4 seconds
8 seconds
Такой подход позволяет серверу восстановиться, вместо того чтобы вызвать настоящий шторм повторных запросов.
6. Контроль конкурентности
Существует проблема, которую может незаметно вызвать функция Promise.all().
Предположим, что необходимо обработать 1 000 запросов к API.
На первый взгляд такой подход кажется довольно безобидным:
await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.
Но это означает, что все 1 000 операций могут быть запущены в точно один и тот же момент.
Это редко бывает тем, чего вы действительно хотите.
Чаще всего предпочтительнее ограничить количество операций, выполняемых параллельно.
Представьте себе что-то вроде этого:
1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time
Создать простой ограничитель конкурентности несложно:
async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}
Вы будете использовать его так:
const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);
Теперь вы сами решаете, сколько задач выполняются одновременно, вместо того чтобы позволять системе решать это за вас.
Этот подход приносит огромную пользу при работе с большими наборами данных, внешними API, обработкой файлов или очередями фоновых задач.
7. Предотвращение дублирующихся запросов
Вот проблема, которая возникает чаще, чем можно было бы ожидать.
Пользователь загружает страницу дашборда.
Три отдельных компонента каждый нуждаются в одних и тех же данных профиля пользователя.
Вместо того чтобы отправлять три отдельных запроса:
Component A → /api/user
Component B → /api/user
Component C → /api/user
их можно заставить делиться одним Promise.
const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}
При такой настройке, если три компонента запрашивают одного и того же пользователя примерно в один и тот же момент, они все подключаются к одному активному запросу вместо того, чтобы запустить три.
Другими словами:
3 запроса → 1 запрос
Эту технику часто называют удалением дубликатов запросов.
Для более крупных кодовых баз инструменты вроде TanStack Query уже реализуют для вас логику кэширования и удаления дубликатов.
8. Используйте Promise.any(), когда вам нужен только один успешный результат
Иногда одни и те же данные доступны из нескольких разных источников.
Например:
API Server A
API Server B
API Server C
Если вашему приложению нужен только один успешный ответ от кого-либо из них, Promise.any() идеально подходит для этой задачи.
const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
Тот Promise, который выполнится первым, выиграет «гонку».
Обратите внимание, что его поведение отличается от поведения Promise.race().
Promise.race() разрешает или отклоняет запрос в зависимости от того, какой Promise установится первым — с успехом или с ошибкой.
Promise.any() в свою очередь ожидает именно первого Promise, который успешно выполнится, игнорируя отклонения, если только все они не завершатся с ошибкой.
Это различие может показаться незначительным, но оно способно полностью изменить подход к обработке ошибок.
9. Отмена работы, которая больше не нужна
Этот паттерн является одним из наиболее эффективных для применения.
Подумайте о поле поиска в реальном времени:
user types: react
user types: react dashboard
user types: react dashboard ui
Вероятно, вы не хотите, чтобы запросы от каждой предыдущей нажатой клавиши продолжали выполняться в фоновом режиме после того, как они устарели.
Именно для такой ситуации и был создан AbortController.
const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});
Как только запрос больше не нужен, достаточно просто вызвать:
controller.abort();
The request can then be cancelled.
Эта схема постоянно встречается в таких сценариях, как:
- Предложения по поиску
- Поля с автодополнением
- Переходы между маршрутами или страницами
- Демонтирование/очистка компонентов
- Запросы, заменяемые более новыми
Настоящий навык здесь заключается не просто в вызове abort().
Это умение распознать момент, когда предыдущие действия перестают быть полезными.
Более важный урок
Объекты Promise кажутся сложными не потому, что .then() — это какой-то сложный API.
Они кажутся сложными потому, что реальные приложения включают запутанное, многоуровневое асинхронное поведение.
Вам постоянно приходится разбираться с такими вещами, как:
Должны ли эти операции выполняться одновременно?
Каков план на случай сбоя одной из них?
Сколько времени считается чрезмерным ожиданием?
Целесообразно ли здесь попытка выполнения операции снова?
Сколько операций должно выполняться одновременно?
Могу ли я избежать дублирования уже начатой работы?
Всё ли ещё важен этот запрос?
Как только вы начинаете задавать подобные вопросы, Promises перестают быть просто языковой функцией.
Они превращаются в настоящий архитектурный инструмент.
Мои 9 шаблонов использования Promises в одном взгляде
Promise.all() идеально подходит для одновременной обработки независимых задач. Promise.allSettled() грамотно справляется с частичными сбоями. Promise.race() используется для обработки тайм-аутов или реакции на первое завершение операции. Логика повторных попыток помогает восстановиться после временных сбоев. Стратегии задержек и пауз предотвращают чрезмерную активность при повторных попытках. Ограничение параллельности помогает контролировать большие объемы работы. Дедупликация запросов исключает повторные вызовы API. Promise.any() возвращает первый успешный результат среди нескольких. AbortController позволяет отменить работу, которая больше не требуется.
Вам не нужны все эти подходы в каждом проекте.
На самом деле их принудительное использование будет контрпродуктивным.
Настоящий навык заключается в том, чтобы определить, какую проблему вы решаете, прежде чем прибегать к определенному подходу.
Самый надёжный асинхронный код — это не тот, который наполнен самыми изощрёнными трюками с Promise.
Это код, в котором обработка ошибок, тайминг, конкурентность и возможность отмены были тщательно проработаны ещё до того, как они привели к проблемам в производственной среде.
Начните с самой простой версии.
Обратите внимание на то, что действительно вызывает проблемы.
Только затем внедряйте конкретные паттерны, решающие эти проблемы.
Потому что иногда самый продвинутый шаг в JavaScript — это понимание того, когда паттерн вообще не нужен.