open atlas
↑ К треку
Node.js с нуля до senior NODE · 02 · 01

Асинхронные паттерны и обработка ошибок

Callback, promise и async/await — три лица одной модели. Композиция через Promise.all, прокидывай rejection вместо проглатывания, отменяй через AbortController и помни: microtask дренируются до следующего macrotask.

NODE Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Обработчик запроса доставал пользователя, потом его заказы, потом позиции каждого заказа — всё через await внутри цикла for. Это работало. И это занимало 4.2 секунды для пользователя с двенадцатью заказами, потому что каждый await блокировал следующий, хотя поиски заказов были полностью независимы. Один ревьюер переписал внутренний цикл как Promise.all(orders.map(fetchLineItems)), и обработчик упал до 380 мс. Те же API, те же данные — разница была лишь в том, ждал ли код того, чего ждать в очередь было незачем.

Callback: контракт error-first и его потолок

Изначальный async-стиль Node — это callback: ты передаёшь функцию, которая запустится, когда работа закончится. Конвенция — error-first: первый аргумент callback’а — это ошибка (или null), остальное — результат. Ты проверяешь ошибку до того, как трогать данные, каждый раз.

fs.readFile("config.json", "utf8", (err, data) => {
  if (err) return console.error("read failed:", err);
  const config = JSON.parse(data); // безопасно только когда err === null
  console.log(config.port);
});

Это работает, но не композируется. Выстрой в цепочку три зависимых операции — и получишь вложенность, заслужившую собственное имя: callback hell, где каждый шаг живёт на отступ глубже, а обработка ошибок скопирована на каждый уровень. Хуже структурная проблема: инверсия управления. Ты отдаёшь своё продолжение в чужую функцию и доверяешь ей вызвать тебя ровно один раз, с правильными аргументами, в правильный тик. Багованная библиотека, которая зовёт callback дважды, или никогда, или синхронно, когда ты ждал async, ломает твой поток — и в системе типов ничто этого не остановит. Promise существуют во многом чтобы забрать это управление обратно.

Promise: значение, которым владеешь ты, с комбинаторами

Почему callback’и оказались настолько неудобны, что язык получил совершенно новую модель? Потому что держать результат у себя — а не отдавать своё продолжение в чужие руки — это то, что делает композицию возможной.

Promise — это объект, представляющий будущее значение. У него три состояния — pending, затем ровно одно из fulfilled (resolve со значением) или rejected (упал с причиной), — и, осев один раз, оно больше не меняется. Поскольку promise — это полноценное значение, которое держишь ты, ты композируешь его вместо передачи управления: .then цепляет преобразования, .catch обрабатывает падения, а rejection пропускает каждый .then, пока не дойдёт до .catch. Этот единственный путь rejection — лекарство от скопированной обработки ошибок.

Настоящая сила — в комбинаторах для одновременного запуска нескольких promise. Выбор неправильного — частая ошибка среднего уровня:

КомбинаторОседает когдаПри rejectionПрименяй для
Promise.allвсе fulfill, или первый rejectreject сразу (fail-fast)all-or-nothing fan-out, где любое падение прерывает
Promise.allSettledкаждый promise оселникогда не reject; статус по каждомунезависимые задачи, где частичный успех ок
Promise.raceпервый осевший (fulfill или reject)reject, если первый осевший упалтаймауты — гонка работы против таймера
Promise.anyпервый fulfillreject, только если все упали (AggregateError)первый годный результат из дублирующих источников

Вместе эти четыре покрывают все формы fan-out: all для all-or-nothing, allSettled для частичного успеха, race для «кто быстрее», any для «первый победитель». Без правильного комбинатора ты или теряешь уже полученные результаты, или прячешь нужные тебе сбои.

Классическая ловушка — использовать Promise.all для задач, где частичный успех допустим: один rejection тогда выбрасывает результаты всего остального, что уже успешно завершилось. Если нужны все результаты независимо от отдельных падений, allSettled — правильный инструмент; all — для случая, когда любое падение действительно инвалидирует весь батч.

async/await: те же promise, читаются сверху вниз

async/await — это синтаксический сахар над promise, а не отдельный механизм. Функция async всегда возвращает promise — даже async () => 42 возвращает promise, который fulfill значением 42, — а await просто ставит функцию на паузу до тех пор, пока promise не осядет, разворачивая его значение или бросая его rejection. Поскольку rejection становится брошенной ошибкой, ты обрабатываешь их обычным try/catch, что читается куда лучше, чем цепочка .catch.

async function loadDashboard(userId) {
  try {
    // независимое — запускаем параллельно, не в очередь
    const [user, orders] = await Promise.all([
      fetchUser(userId),
      fetchOrders(userId),
    ]);
    return { user, orders };
  } catch (err) {
    // один catch покрывает оба rejection
    throw new Error(`dashboard load failed for ${userId}`, { cause: err });
  }
}

Самый частый баг производительности здесь — последовательный await в цикле, когда итерации независимы. for (const id of ids) { await fetchOne(id); } запускает запросы один за другим; суммарное время — это сумма всех задержек. Если вызовы не зависят друг от друга, запусти их вместе через Promise.all(ids.map(fetchOne)), и суммарное время схлопнется примерно до самого медленного одного вызова. Последовательный await корректен только когда каждому шагу действительно нужен результат предыдущего.

Почему это работает

Не мешай await и .then беспечно на одной операции. await fetchUser().then(u => u.name) работает, но затемняет, куда падают ошибки, и читается как две парадигмы, сшитые степлером. Выбирай одну на логический поток. Тоньше баг: запустить promise и заawait’ить его позже — нормально и даже полезно для параллелизма, но если ты создал promise и никогда не await’ишь и не .catch’ишь его, позднейший rejection становится unhandled rejection — см. обработку ошибок ниже.

Обработка ошибок: никогда не проглатывай, решай прокинуть vs обработать

Async-ошибка, которую ты игнорируешь, не исчезает — она становится unhandledRejection. В современном Node promise, который reject без .catch (и без окружающего try/catch на его await), эмитит process-событие unhandledRejection и по умолчанию роняет процесс. Это умолчание намеренно: наполовину завершённый запрос в неизвестном состоянии опаснее, чем перезапуск.

Правило — никогда не проглатывай ошибки: пустой catch {}, который ничего не логирует, — это то, как аварии становятся необъяснимыми. На каждое падение ты принимаешь одно решение: обработать его (можешь восстановиться — retry, откат к дефолту, вернуть 503) или прокинуть его (не можешь, поэтому re-throw и дай вызывающему с бо́льшим контекстом решить). Блок catch, который ни восстанавливает, ни re-throw’ит, почти всегда баг.

Отмена — вторая половина. Запрос, который пользователь бросил, или fetch, бежавший слишком долго, надо остановить — и современный, стандартный инструмент это AbortController. Ты передаёшь его signal в async-API; вызов controller.abort() reject’ит операцию с AbortError. Привязка к таймеру даёт чистый таймаут без утечки выполняющейся работы:

async function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(timer); // всегда чистим таймер
  }
}

Порядок event loop: microtask дренируются до следующего macrotask

Сеньорский вопрос — когда твои callback’и реально запускаются. Event loop обрабатывает macrotask (таймеры вроде setTimeout и I/O-callback’и) по одному, — но после каждого macrotask, прежде чем перейти к следующему, он полностью дренирует очередь microtask: callback’и осевших promise, queueMicrotask и process.nextTick Node. Microtask всегда бегут до следующего macrotask, а внутри microtask Node запускает очередь nextTick впереди очереди promise.

Этот порядок объясняет вывод, который удивляет:

console.log("1: sync");
setTimeout(() => console.log("2: timeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: promise (microtask)"));
queueMicrotask(() => console.log("4: queueMicrotask (microtask)"));
process.nextTick(() => console.log("5: nextTick (microtask, first)"));
console.log("6: sync");
// 1: sync → 6: sync → 5: nextTick → 3: promise → 4: queueMicrotask → 2: timeout

Сначала бегут обе синхронные строки. Затем текущая операция заканчивается и очередь microtask дренируется — nextTick впереди задач promise/queueMicrotask, — и только после опустошения очереди срабатывает macrotask setTimeout, хотя его задержка была 0. Практическая выгода: callback process.nextTick может заморить I/O, если продолжает планировать новую работу nextTick, а цепочка promise никогда не уступает таймеру посреди дренажа. Знание порядка — это то, как ты рассуждаешь, почему таймаут «0 мс» всё равно бежит последним.

Выбери лучший вариант

Ты делаешь fan-out из N независимых API-вызовов. Некоторые могут упасть, и ты хочешь использовать каждый успешный результат. Какой комбинатор?

Викторина

Цикл делает `for (const id of ids) { results.push(await fetchOne(id)); }`, и вызовы независимы. В чём проблема и фикс?

Викторина

Что это выведет, по порядку? `console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');`

Вспомните перед уходом
  1. 01
    Когда использовать Promise.all vs allSettled vs race vs any?
  2. 02
    Объясни, почему callback setTimeout(fn, 0) бежит после callback Promise.then, запланированного до него.
Итог

Callback, promise и async/await — три синтаксиса над одной асинхронной моделью. Callback’и используют контракт error-first (err, data), но не композируются и отдают управление в чужую функцию; promise забирают это управление обратно как значение, которым владеешь ты, с единственным путём rejection через .catch и комбинаторами — Promise.all для fail-fast all-or-nothing fan-out, allSettled, когда важен частичный успех, race для таймаутов, any для первого годного результата. async/await — сахар над promise: async-функция всегда возвращает promise, await разворачивает или бросает, а падения ты обрабатываешь через try/catch. Повторяющийся баг производительности — последовательный await в цикле над независимой работой; распараллель его через Promise.all. Для ошибок никогда не проглатывай rejection (он становится unhandledRejection и может уронить процесс); на каждое падение решай, обработать или прокинуть, а долгую или брошенную работу отменяй через AbortController плюс таймер. Наконец, event loop дренирует всю очередь microtask — promise, queueMicrotask и process.nextTick (который бежит первым) — после текущей операции и до следующего macrotask, поэтому Promise.then всегда обгоняет setTimeout(…, 0). Теперь, когда видишь медленный обработчик на ревью, первый вопрос: эти await-ы в цикле действительно последовательны или их можно схлопнуть в Promise.all? Один вопрос ловит самую частую регрессию производительности в async-коде.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.