JavaScript’s async/await syntax was supposed to save us from callback hell. Instead, it introduced a quieter, more insidious problem: code that looks correct but behaves unpredictably. You see await sitting there in the function body and assume everything pauses politely, line by line. Often it does not. Loops race ahead. Entire batches collapse because of a single failed request. Entry files sprout ugly async wrappers for no reason. If you have run into any of these, these three patterns will straighten them out.
Stop Using await Inside forEach
Here is a common mistake that looks harmless on the page:
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!');
Run this, and 'All done!' prints before a single response comes back. Why? forEach executes the callback for every element immediately. It does not wait for the promise inside each iteration. The async keyword turns each callback into a promise that forEach promptly ignores. Your loop finishes in microseconds; the network requests wander off on their own. If you need errors handled in order, or if you need to guarantee one request finishes before the next begins, this pattern silently breaks both guarantees.
Replace it with a for...of loop:
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!');
Now the loop genuinely halts at each await. The second request waits for the first. 'All done!' prints only after everything settles.
Use for...of when sequence matters—uploading files one at a time to respect rate limits, writing database rows in a specific order, or chaining API calls where the next request needs data from the previous response. If you actually want parallel execution, do not fumble around with forEach. Reach for Promise.all explicitly so your intent is visible to the next developer. But never mix await and forEach expecting synchronous behavior. It will not happen.
Reach for Promise.allSettled When Zero Cannot Be the Answer
Promise.all is semantically honest. Hand it an array of promises, and it returns an array of results. The catch is that the moment any single promise rejects, the whole thing rejects immediately. Every other pending promise is left to finish on its own, but you lose access to their results. In production, this all-or-nothing behavior stings.
Imagine your application fetches a dashboard’s widgets from four independent services: traffic analytics, revenue data, user feedback, and server health. The revenue API hits a brief timeout. Under Promise.all, your entire dashboard throws an error. The three healthy responses vanish into the void. The user sees a spinner, then a failure screen, because one quarter of the data misbehaved.
Promise.allSettled gives you a saner contract. It waits until every single promise finishes, whichever way it goes. The resolved value is an array of objects describing each outcome:
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);
}
});
No response gets discarded. You render what you can and isolate the failure. This pattern matters whenever you are dealing with unrelated operations—bulk notifications, third-party webhook dispatches, or importing records from multiple CSV streams. You still need centralized error tracking, but your application keeps its footing.
One practical note: allSettled returns the full set, so you still need to sift through the results and decide what "partial success" means for your feature. Do not treat the returned array as uniformly happy data. Check those status fields before you push anything into your state layer.
Declare Top-Level await and Kill the Wrapper IIFE
For years, if you wanted to await something at the root of a file, you wrapped it in an immediately invoked async function:
(async () => {
const config = await loadConfig();
startServer(config);
})();
This works, but it is noise. Top-level await, native in ES modules, lets you drop the ceremonial boilerplate:
const config = await loadConfig();
startServer(config);
Use this at the entry point of your application or in dedicated configuration modules where initialization must complete before anything else runs. Loading environment files, establishing a database connection pool, or fetching remote feature flags are all natural fits. Because top-level await blocks execution of the module graph—other files importing this one will wait for your promise to resolve—you get a guaranteed state. The rest of your codebase can import db and know the connection is already live.
Есть два нюанса. Во-первых, ваша среда выполнения или бандлер должны поддерживать ES-модули. В 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 напрямую управлять последовательностью инициализации. Это небольшие механические изменения, но вместе они превращают хрупкие асинхронные скрипты в код, которому действительно можно доверять.
