Синтаксис async/await у JavaScript мав врятувати нас від «пекла колбеків» (callback hell). Натомість він приніс тихішу та підступнішу проблему: код, який виглядає правильним, але поводиться непередбачувано. Ви бачите await у тілі функції й припускаєте, що все ввічливо зупиняється, рядок за рядком. Часто це не так. Цикли пролітають наперед. Цілі пачки запитів руйнуються через один невдалий запит. Файли точок входу без причини обростають потворними async-обгортками. Якщо ви стикалися з чимось із цього, ці три патерни допоможуть усе виправити.

Припиніть використовувати await всередині forEach

Ось поширена помилка, яка на перший погляд здається нешкідливою:

const urls = ['/api/user', '/api/posts', '/api/comments'];

urls.forEach(async (url) => {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
});

console.log('All done!');

Запустіть це, і 'All done!' виведеться ще до того, як прийде хоча б одна відповідь. Чому? forEach негайно виконує колбек для кожного елемента. Він не чекає на проміс у кожній ітерації. Ключове слово async перетворює кожен колбек на проміс, який forEach просто ігнорує. Ваш цикл завершується за мікросекунди, а мережеві запити продовжують працювати самі по собі. Якщо вам потрібно обробляти помилки послідовно або гарантувати, що один запит завершиться перед початком наступного, цей патерн непомітно порушує обидві гарантії.

Замініть його на цикл for...of:

const urls = ['/api/user', '/api/posts', '/api/comments'];

for (const url of urls) {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
}

console.log('All done!');

Тепер цикл дійсно зупиняється на кожному await. Другий запит чекає на перший. 'All done!' виводиться лише після того, як усе завершиться.

Використовуйте for...of, коли важлива послідовність: завантаження файлів по одному, щоб не перевищити ліміти запитів (rate limits), запис рядків у базу даних у певному порядку або ланцюжок викликів API, де наступний запит потребує даних із попередньої відповіді. Якщо вам справді потрібне паралельне виконання, не намагайтеся маніпулювати forEach. Використовуйте Promise.all явно, щоб ваш намір був зрозумілим іншим розробникам. Але ніколи не змішуйте await та forEach, очікуючи синхронної поведінки. Цього не станеться.

Використовуйте Promise.allSettled, коли нуль не є прийнятною відповіддю

Promise.all є семантично чесним. Передайте йому масив промісів, і він поверне масив результатів. Підвох полягає в тому, що щойно будь-який один проміс відхиляється (reject), увесь процес негайно завершується помилкою. Усі інші незавершені проміси продовжують виконуватися самі по собі, але ви втрачаєте доступ до їхніх результатів. У реальних проєктах така поведінка за принципом «все або нічого» є дуже болючою.

Уявіть, що ваш застосунок отримує віджети для панелі керування (dashboard) з чотирьох незалежних сервісів: аналітики трафіку, даних про дохід, відгуків користувачів та стану сервера. API доходів видає короткий таймаут. При використанні Promise.all вся ваша панель керування видасть помилку. Три успішні відповіді просто зникнуть у небутті. Користувач побачить спінер, а потім екран помилки лише тому, що одна чверть даних спрацювала некоректно.

Promise.allSettled пропонує більш розумний підхід. Він чекає, поки кожен проміс завершиться, незалежно від результату. Повернене значення — це масив об'єктів, що описують кожен результат:

const requests = [
  fetch('/api/traffic'),
  fetch('/api/revenue'),
  fetch('/api/feedback'),
  fetch('/api/health')
];

const results = await Promise.allSettled(requests);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    renderWidget(index, result.value);
  } else {
    renderError(index, result.reason);
  }
});

Жодна відповідь не втрачається. Ви відображаєте те, що вдалося отримати, і ізолюєте помилку. Цей патерн важливий щоразу, коли ви маєте справу з непов'язаними операціями: масовими розсилками, відправкою вебхуків стороннім сервісам або імпортом записів із кількох CSV-потоків. Вам все ще потрібне централізоване відстеження помилок, але ваш застосунок при цьому залишається стабільним.

Практична примітка: allSettled повертає повний набір результатів, тому вам все одно потрібно перебрати їх і вирішити, що означає «частковий успіх» для вашої функції. Не сприймайте повернутий масив як суцільно успішні дані. Перевіряйте поля status, перш ніж передавати щось у шар стану (state layer).

Використовуйте Top-level await і позбудьтеся обгортки IIFE

Роками, якщо ви хотіли використати await у корені файлу, вам доводилося загортати його в асинхронну функцію, що викликається негайно (IIFE):

(async () => {
  const config = await loadConfig();
  startServer(config);
})();

Це працює, але створює зайвий шум. Top-level await, який є нативним для ES-модулів, дозволяє позбутися цього церемоніального шаблонного коду:

const config = await loadConfig();
startServer(config);

Використовуйте це в точці входу вашого застосунку або в спеціальних модулях конфігурації, де ініціалізація має завершитися до запуску всього іншого. Завантаження файлів середовища, створення пулу з'єднань із базою даних або отримання віддалених прапорців функціональності (feature flags) — усе це ідеально підходить для такого підходу. Оскільки top-level await блокує виконання графа модулів (інші файли, що імпортують цей, чекатимуть на виконання вашого промісу), ви отримуєте гарантований стан. Решта вашого коду може імпортувати db і бути впевненою, що з'єднання вже встановлено.

Є два нюанси. По-перше, ваше середовище виконання або бандлер мають підтримувати ES modules. У Node.js це означає або використання розширень .mjs, або встановлення "type": "module" у вашому package.json. По-друге, оскільки очікування на рівні модуля затримує кожного імпортера, тримайте очікувану роботу зосередженою. Важкі послідовні запити на початку файлу утиліт, що часто імпортується, сповільнять холодний запуск усього вашого застосунку. Залишайте top-level await для справжніх завдань ініціалізації, від яких дійсно залежать інші модулі.

Що насправді змінюється, коли ви впроваджуєте ці патерни

Перша перевага — це передбачуваність. Коли ви читаєте цикл for...of, ви точно знаєте, коли завершиться блок коду всередині нього. Немає «промісів-привидів», що змагаються за лаштунками, немає колбеків foreach, які відриваються від ваших обробників помилок. Ваш потік керування відповідає структурі коду на екрані.

Далі йде стійкість. Promise.allSettled змушує вас думати про часткові збої замість того, щоб сподіватися, що кожна зовнішня система працюватиме ідеально. Програмне забезпечення у продакшені не є бінарним. Деякі ендпоінти працюватимуть нестабільно. Деякі читання файлів призведуть до помилок доступу. Проєктування з урахуванням реальності розрізнених збоїв дозволяє вашому застосунку залишатися стабільним, не приховуючи при цьому легітимні дані.

Чіткість об'єднує все це докупи. for...of читається як звичайний послідовний виклад. allSettled чітко заявляє про свій намір у самій назві. Top-level await усуває загадкові обгортки IIFE, тому ваші вхідні файли починаються з бізнес-логіки, а не з синтаксичної акробатики. Наступний інженер, який працюватиме з цим файлом — чи то ви через шість місяців, чи то колега в умовах стислих термінів — подякує вам.

Практичний висновок

Не сприймайте async/await як універсальне рішення, яке можна просто посипати поверх існуючого коду. Проведіть аудит своїх поточних проєктів на наявність цих трьох конкретних антипатернів. Шукайте await всередині блоків forEach і замінюйте їх на for...of або цілеспрямований Promise.all. Перегляньте кожен Promise.all, який взаємодіє із зовнішніми сервісами, і запитайте себе: чи справді один збій має повністю зруйнувати всю операцію? Якщо ні, перейдіть на Promise.allSettled і обробіть змішані результати. Нарешті, видаліть асинхронні IIFE з точок входу ваших ES-модулів і дозвольте top-level await безпосередньо керувати послідовністю ініціалізації. Це невеликі механічні зміни, але разом вони перетворюють крихкі асинхронні скрипти на код, якому ви дійсно можете довіряти.