Головна / Статті / Дев’ять шаблонів обіцянок для надійного асинхронного JavaScript у продакшені

Дев’ять шаблонів обіцянок для надійного асинхронного JavaScript у продакшені

Дізнайтеся про практичні шаблони Promise — паралельні запити, таймаути, повторні спроби, обмеження конкурентності та скасування — для створення стійкого асинхронного JavaScript промислового рівня.

1998 слів

Ви постійно користуєтесь Promises, проте кілька менш відомих патернів можуть перетворити заплутаний асинхронний код на щось прогнозоване та зрозуміле для аналізу.

Більшість людей ознайомлюються з Promises через подібний приклад:

fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));

І для простих скриптів це справді все, що потрібно.

Проблеми починаються, коли ви переходите до додатків промислового рівня.

Раптово вам потрібно, щоб кілька запитів виконувалися одночасно, а не по черзі.

Вам потрібен спосіб скасування роботи, яка більше не є актуальною.

Вам потрібні спроби повторного виконання у разі невдачі запиту.

Вам потрібно продовжувати роботу навіть тоді, коли лише частина операції вдається.

Вам потрібно запобігти випадковому подвійному виклику одного й того ж API.

І іноді вам потрібно обмежити кількість асинхронних операцій, які виконуються одночасно, щоб ваш бекенд не отримував усього одночасно.

Саме тут обіцянки перестають бути темою для початківців та стають справжнім інструментом проектування.

Нижче наведено дев’ять шаблонів, які варто мати у своєму арсеналі під час написання сучасного 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().

Це здатність розпізнати момент, коли попередня робота більше не є корисною.

Більш важливий урок

Обіцянки не здаються складними через те, що .then() — це якась складна API.

Вони здаються складними тому, що реальні додатки передбачають заплутану, багаторівневу асинхронну поведінку.

Вам постійно доводиться розбиратися з такими речами, як:

Чи слід виконувати ці операції одночасно?

Який план у разі провалу однієї з них?

Скільки часу — це занадто довго для очікування?

Чи варто тут намагатися ще раз?

Скільки операцій слід виконувати одночасно?

Чи можу я уникнути дублювання роботи, яка вже триває?

Чи все ще має значення цей запит?

Як тільки ви починаєте ставити такі запитання, Promises перестають бути просто функцією мови.

Вони перетворюються на справжній архітектурний інструмент.

Мої 9 шаблонів використання Promises у короткому огляді

Promise.all() — найкращий варіант для одночасного виконання незалежних завдань. Promise.allSettled() гідно справляється з частковими помилками. Promise.race() підходить для обробки тайм-аутів або реакції на те, що вирішиться першим. Логіка повторних спроб допомагає виправитися після тимчасових невдач. Стратегії затримки та поступового збільшення інтервалів запобігають занадто агресивним спробам. Обмеження конкурентності допомагає контролювати великі обсяги роботи. Дедуплікація запитів запобігає подвійним викликам API. Promise.any() повертає перший успішний результат серед кількох. AbortController дозволяє скасувати роботу, яка більше не є необхідною.

У кожному проєкті не потрібні всі ці патерни.

Насправді їх примусове використання буде контрпродуктивним.

Справжня навичка полягає у тому, щоб визначити, яку проблему ви вирішуєте, перш ніж вдаватися до певного патерна.

Останні думки

Найкращий асинхронний код — це не той, який наповнений найхитрішими трюками з Promise.

Це код, у якому обробка помилок, таймінг, конкурентність та скасування операцій були ретельно пророблені ще до того, як вони перетворилися на проблеми у продакшені.

Почніть з найпростішої версії.

Зверніть увагу на те, що насправді створює проблеми.

Лише тоді використовуйте конкретну схему, яка вирішує цю проблему.

Адже іноді найкращим кроком у JavaScript є усвідомлення того, коли схема зовсім не потрібна.

Пов’язана література

  • Вибір між Promise.all, Promise.race та послідовними Awaits — Дізнайтеся, коли Promise.all() прискорює роботу API Node.js, чому він швидко зазнає невдачі при будь-якому відхиленні, та яка система прийняття рішень для вибору правильного асинхронного паттерну.
  • Async/Await проти Promises: У чому насправді різниця — Пояснює справжні відмінності у виконанні, використанні пам’яті та стек-трейсі між async/await та Promises, а також коли все ж варто використовувати базові API Promise.