async/await, AbortController, top-level await
async/await — это не новый механизм, а синтаксис над промисами и генераторами. Но знание этого факта само по себе ничего не даёт; важно понимать, как именно await приостанавливает функцию, где он теряет параллельность и как правильно отменять начатые операции. Три темы этой главы — самые практичные: параллельные запросы, отмена через AbortController и потоковая обработка через async-генераторы.
Как await работает под капотом
Заголовок раздела «Как await работает под капотом»async-функция при вызове немедленно возвращает промис. Тело выполняется синхронно до первого await, затем функция приостанавливается — управление возвращается вызывающему коду, а продолжение (всё после await) ставится в очередь микрозадач и выполнится, когда промис разрешится:
async function demo() { console.log('A'); // синхронно при вызове const v = await Promise.resolve(42); console.log('B, получили:', v); // микрозадача: продолжение после await return v * 2; // результат — resolve промиса функции}
const p = demo();console.log('C — функция уже вернула промис');p.then((r) => console.log('D, итог:', r));// A → C → B → D (и итог: 84)Механика приостановки: движок сохраняет локальные переменные и позицию выполнения (как у генератора — ранее async-функции буквально транспилировались в генераторы + yield). Поэтому цикл с await «помнит» свою итерацию — это не многопоточность, а кооперативная многозадачность в одном потоке.
Ошибки внутри async-функции (включая rejected await) превращаются в reject возвращаемого промиса — отсюда try/catch/finally вокруг await, как вокруг синхронного кода.
await в циклах: последовательность против параллельности
Заголовок раздела «await в циклах: последовательность против параллельности»Классическая ошибка — await в for при загрузке независимых данных:
// ПЛОХО: запросы идут ОДИН ЗА ДРУГИМ — время = сумма задержекasync function loadSequentially(ids) { const users = []; for (const id of ids) { const r = await fetch(`/api/users/${id}`); users.push(await r.json()); } return users; // 10 запросов × 300 мс = ~3 секунды}// ХОРОШО: запросы стартуют сразу все, ждём вместеasync function loadParallel(ids) { const users = await Promise.all( ids.map(async (id) => { const r = await fetch(`/api/users/${id}`); return r.json(); }) ); return users; // ~300 мс независимо от количества (в пределах лимитов)}Но есть ситуации, где последовательность нужна:
- Зависимость данных: сначала пользователь, потом его заказы (нужен
user.id). - Rate limiting: API разрешает N запросов/сек; параллельный залп получит 429.
- Ограничение нагрузки: чужой сервер, не флудим.
Компромисс — ограниченная параллельность (пул). Реализация через пакеты:
async function mapWithConcurrency(items, limit, worker) { const results = new Array(items.length); let cursor = 0;
async function runner() { while (cursor < items.length) { const index = cursor++; results[index] = await worker(items[index], index); } }
// Запускаем `limit` «рабочих», каждый берёт следующий элемент await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner)); return results;}
// Использование: не более 3 одновременных запросовconst avatars = await mapWithConcurrency(userIds, 3, async (id) => { const r = await fetch(`/api/users/${id}/avatar`); return r.blob();});Отмена операций: AbortController
Заголовок раздела «Отмена операций: AbortController»fetch, addEventListener, ReadableStream и большинство современных асинхронных API принимают signal — объект-«флажок отмены». AbortController — источник этого сигнала:
const controller = new AbortController();const { signal } = controller;
// Отменим всё через 5 секундconst timerId = setTimeout(() => controller.abort(), 5000);
try { const r = await fetch('/api/big-report', { signal }); const data = await r.json(); renderReport(data);} catch (err) { if (err.name === 'AbortError') { console.log('Запрос отменён (таймаут)'); } else { console.error('Сетевая ошибка:', err); }} finally { clearTimeout(timerId);}
// Любой момент: controller.abort() → все слушатели сигнала отменяютсяОдин контроллер отменяет связку операций — идеально для «пользователь ушёл со страницы»:
function createPageLoader(signal) { return async function loadEverything() { const [user, feed, ads] = await Promise.all([ fetch('/api/user', { signal }).then((r) => r.json()), fetch('/api/feed', { signal }).then((r) => r.json()), fetch('/api/ads', { signal }).then((r) => r.json()), ]); return { user, feed, ads }; };}
// В обработчике перехода:const controller = new AbortController();pageLoader = createPageLoader(controller.signal);pageLoader().catch((e) => e.name === 'AbortError' || showError(e));
// При уходе со страницы:controller.abort(); // все три fetch отменены разомAbortSignal.timeout — встроенный таймаут
Заголовок раздела «AbortSignal.timeout — встроенный таймаут»Современные браузеры и Node 17.3+ имеют встроенный таймаут без ручного setTimeout:
// Отмена fetch через 8 секунд, без контроллера и clearTimeoutconst r = await fetch('/api/slow-endpoint', { signal: AbortSignal.timeout(8000) });// При срабатывании: AbortError с именем 'AbortError' (причина — TimeoutError)Комбинирование сигналов
Заголовок раздела «Комбинирование сигналов»Отменять при «любой из причин» — через AbortSignal.any (браузеры, Node 20.3+):
const pageLeave = new AbortController();const userCancel = new AbortController();
const signal = AbortSignal.any([pageLeave.signal, userCancel.signal, AbortSignal.timeout(10_000)]);await fetch('/api/data', { signal }); // отменится при уходе, клике «отмена» или 10 сСвоя отменяемая операция
Заголовок раздела «Своя отменяемая операция»Если пишешь асинхронную утилиту — принимай signal и реагируй на него:
function delay(ms, { signal } = {}) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException('Отменено', 'AbortError')); return; } const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); reject(new DOMException('Отменено', 'AbortError')); } signal?.addEventListener('abort', onAbort, { once: true }); });}
// Теперь delay отменяем:const c = new AbortController();delay(5000, { signal: c.signal }).catch((e) => console.log(e.name)); // AbortErrorsetTimeout(() => c.abort(), 100);Top-level await
Заголовок раздела «Top-level await»В ES-модулях await разрешён на верхнем уровне (top-level await, v8.dev) — без обёртки в async-функцию. Модуль «зависает» в состоянии загрузки, пока все top-level await не разрешатся, и экспорты становятся доступны только после:
// config.ts — модуль не считается загруженным, пока конфиг не полученconst response = await fetch('/config.json');if (!response.ok) throw new Error('Конфиг недоступен — приложение не стартует');
export const config = await response.json();export const apiUrl = config.apiUrl;// main.ts — импорт «ждёт» config.ts автоматическиimport { apiUrl } from './config';// здесь apiUrl гарантированно инициализированГде это полезно:
- Инициализация перед экспортом (конфиг, подключение к БД, feature-flags).
- Динамический импорт с условием:
const mod = await import(cond ? './a' : './b'). - Fallback-цепочки:
let adapter; try { adapter = await import(' sharp'); } catch { adapter = await import('jimp'); }.
Ограничения:
- Только ES-модули (
type: "module"или.mjs). CommonJS не поддерживает. - Блокирует дерево импорта: модуль-потребитель не выполнится раньше. Держи top-level await короткими; тяжёлую работу — в экспортированную async-функцию.
- В библиотеках — осторожно: потребители не ожидают, что
importбудет ждать сеть.
Async-генераторы и for await
Заголовок раздела «Async-генераторы и for await»Генераторы (function*) лениво производят значения через yield. Async-генераторы (async function*) производят значения асинхронно — каждый yield может ждать промис:
// Поток чтения большого списка порциями: не грузим всё в памятьasync function* fetchUsersInBatches(baseUrl, batchSize = 50) { let page = 1; while (true) { const r = await fetch(`${baseUrl}?page=${page}&limit=${batchSize}`); const batch = await r.json(); if (batch.length === 0) return; // конец потока for (const user of batch) { yield user; // лениво отдаём по одному } page += 1; }}
// Потребление: for await...of автоматически вызывает .next() и ждёт промисыfor await (const user of fetchUsersInBatches('/api/users')) { renderUserCard(user); if (user.id === targetId) break; // генератор корректно завершится (return вызовется)}Ключевые свойства:
for await...ofработает с async-итерабелями (объект с[Symbol.asyncIterator]) и с обычными итерируемыми (массив промисов).break/returnв цикле вызываетgenerator.return()— finally-блоки внутри генератора выполнятся (отмена fetch, закрытие курсора).- Async-генераторы — основа streaming-API: чтение файлов/ответов по кускам, подписки на события как потоки.
Пример с обработкой потока ответа (Web Streams API):
async function* readLines(stream) { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) >= 0) { yield buffer.slice(0, idx); buffer = buffer.slice(idx + 1); } } if (buffer) yield buffer; } finally { reader.releaseLock(); // освобождаем ресурс при break/ошибке }}
const response = await fetch('/api/logs?follow=1');for await (const line of readLines(response.body)) { console.log('лог:', line);}Ошибки в параллельных операциях: точечный контроль
Заголовок раздела «Ошибки в параллельных операциях: точечный контроль»Когда в Promise.all падает один запрос из пяти, весь комбинатор rejected — и ты теряешь результаты остальных четырёх. В продакшене это часто не то, что нужно: страница профиля может отрисоваться без блока «рекомендации», но не без блока «основная информация». Решение — разделить критичность:
async function loadProfilePage(userId) { // Критично: без этого страница бессмысленна — пусть весь рендер падает const user = await loadUser(userId);
// Некритично: каждый блок грузим изолированно, ошибка → запасной контент const [stats, friends, activity] = await Promise.all([ loadStats(userId).catch(() => null), // «недоступно» loadFriends(userId).catch(() => []), // пустой список loadActivity(userId).catch(() => []), // пустая лента ]);
return { user, stats, friends, activity };}Приём .catch(() => fallback) внутри Promise.all — стандартный способ сказать «этот кусок опционален». Важно ловить локально, а не оборачивать весь all в один try/catch: иначе не узнаешь, какой именно запрос упал, и не покажешь точечный fallback.
Если же нужно знать детали всех падений (например, для телеметрии), используй allSettled и разбирай статусы явно:
const results = await Promise.allSettled([loadA(), loadB(), loadC()]);const failures = results .map((r, i) => ({ ...r, index: i })) .filter((r) => r.status === 'rejected');if (failures.length) { reportToSentry('Частичная загрузка профиля', { failures });}Этот паттерн — разновидность «circuit breaker на минималках»: система деградирует частично, но не падает целиком. В полноценном виде он вырастает в отдельный модуль устойчивости (retry, fallback-источники, дедлайны), но основа — именно разделение критичных и некритичных операций на уровне вызова.
Async-генераторы как публичный API потоков
Заголовок раздела «Async-генераторы как публичный API потоков»Если твоя функция возвращает поток данных, async-генератор часто честнее, чем «массив всего сразу» или колбэк onItem. Сравни контракты:
// Вариант 1: массив — ждём всех перед стартом, память под всё сразуconst users = await fetchAllUsers(); // 100 000 записей в памяти
// Вариант 2: колбэки — инверсия контроля, сложно остановитьstreamUsers((user) => render(user), { onDone, onError });
// Вариант 3: async-генератор — потребитель решает, когда брать следующийfor await (const user of streamUsers()) { render(user); if (enoughRendered()) break; // генератор завершится, ресурсы освобождены}Потребитель с for await получает полный контроль: пауза между итерациями (просто не вызывать next), ранний выход (break вызывает return() генератора), обработку ошибок обычным try/catch. А реализация скрывает детали постраничной загрузки, реконнекты и бэкоффы внутри.
Правило проектирования: если данные приходят порциями и потребитель может обработать их по частям — возвращай async-итерабель. Если нужен один конкретный результат — промис. Если «всё или ничего» — массив.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
awaitвforEach/mapбезPromise.all.forEachне ждёт;mapвозвращает массив промисов, которые никто не ждёт — unhandled rejection при ошибках. См. блок выше. -
Последовательные запросы, где нужна параллельность. Задержки суммируются. Проверяй: независимы ли запросы? Если да —
Promise.all. -
Параллельный залп без лимита на сотнях элементов — бан по rate limit, перегрузка памяти. Используй пул из примера
mapWithConcurrency. -
Забытый
AbortControllerпри SPA-навигации. Пользователь ушёл, а fetch продолжает качать мегабайты и потом пишет в размонтированный компонент. Передавайsignalвсем запросам страницы. -
Путать
AbortErrorс реальной ошибкой — показывать тост «Сеть недоступна» при штатной отмене. Проверяйerr.nameпервым делом. -
Тяжёлый top-level await в библиотечном модуле — потребители блокируются неожиданно. Документируй или убери в функцию.
-
Не закрытый async-генератор:
breakбез finally в генераторе — удержание ресурсов (reader, соединение). Всегдаtry/finallyс очисткой.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что возвращает async-функция и что делает await внутри неё?
Промис.
awaitприостанавливает функцию до разрешения промиса, продолжение — микрозадача; ошибки превращаются в reject возвращаемого промиса. - Как выполнить независимые запросы параллельно?
Promise.all(ids.map(async id => ...))— стартуют сразу все, порядок результатов сохранён. Для лимита — пул рабочих поверхPromise.all. - Как отменить fetch? Что такое AbortSignal.timeout?
Передать
{ signal }отAbortControllerи вызватьabort().AbortSignal.timeout(ms)— встроенный сигнал с автоматическим прерыванием черезms(Node 17.3+, современные браузеры). - Что такое top-level await и где работает?
awaitна верхнем уровне ES-модуля; модуль не завершает загрузку, пока не разрешатся все await. Только ESM (браузер, Node 14.8+ с.mjs). - Чем async-генератор отличается от обычного и как его потреблять?
async function*можетawaitвнутри иyieldпромисы; потребление черезfor await...of, который ждёт каждыйnext(). Корректно завершается черезbreak(вызоветreturn()). - Почему
arr.forEach(async ...)не работает как ожидается?forEachигнорирует промисы колбэков: не ждёт завершения и не ловит ошибки. Замена —for...of+ await илиPromise.all(arr.map(...)).
Практика
Заголовок раздела «Практика»- Напиши
runWithConcurrency(tasks, limit): принимает массив функций-задач (каждая возвращает промис) и лимит одновременных выполнений. Критерий: не болееlimitактивных задач (проверь счётчиком), порядок результатов — порядок задач, ошибка любой задачи отклоняет итог. - Реализуй
fetchWithRetry(url, { retries, delay, signal }): fetch с N повторами при ошибках, бэкофф задержкиdelay * 2^n, полный уважение кsignal(отмена прекращает ретраи). Критерий: сервер, падающий первые 2 раза, успешно обслуживает с 3-й попытки. - Создай утилиту
createCancellable(): возвращает{ promise, cancel }, гдеpromiseрезолвится черезresolve(value), аcancel()отклоняет сAbortError. Сымитируй компонент: две загрузки, отменяемые однимcancel(). - Напиши
watchFileChanges(path)— async-генератор, лениво читающий растущий файл порциями (имитацияtail -f): каждые 500 мс проверяет новые строки иyield-ит их. Критерий:breakизfor awaitостанавливает таймер (finally), нет утечки. - Перепиши последовательный код на параллельный и обоснуй: загрузка пользователя, его заказов и рекомендаций; заказы зависят от
user.id. Найди компромисс (заказы+рекомендации параллельно после пользователя) и реализуй его.