Як обробляти async/await в обробниках маршрутів Express.js?
Async/await в обробниках маршрутів Express - Express не перехоплює відхилення Promise з async-обробників автоматично, тому необроблені помилки повністю оминають middleware для обробки помилок.
Теорія
TL;DR
- Express створили до появи async/await. Його система обробки помилок синхронна.
- Коли async-обробник відхиляється, відхилення стає в чергу мікрозадач. Express вже вийшов зі свого try/catch на той момент.
- Необроблені відхилення не потрапляють до error middleware - вони або кладуть процес, або генерують попередження.
- Рішення: загортай async-обробники у функцію, яка викликає
.catch(next). - Правило вибору: один
asyncHandlerwrapper покриває всі async-маршрути і middleware.
Швидкий приклад
// ❌ ЗЛАМАНО - відхилення ніколи не доходить до error handler
app.get('/users', async (req, res) => {
const users = await User.findAll(); // падає? Додаток крашиться.
res.json(users);
});
// ✅ ПРАВИЛЬНО - відхилення передається в error middleware
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));
// Error middleware тепер отримує всі async-помилки
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Wrapper перехоплює будь-яке відхилення з async-функції і викликає next(err). Express спрямовує його до error middleware. Більше нічого не змінюється.
Чому Express не ловить async-помилки
Система middleware Express синхронна в своїй основі. Коли ти await-ш всередині обробника, Node.js повертає Promise. Якщо той Promise відхиляється, відхилення стає в чергу мікрозадач. Express вже вийшов зі свого синхронного try/catch на той момент. Він просто не бачить цього відхилення.
Це не баг. Express вийшов у 2010 році, за сім років до async/await. Архітектура була правильною для свого часу.
Wrapper asyncHandler
Патерн виглядає просто, але робить реальну роботу:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};Promise.resolve() загортає повернуте значення обробника в Promise, навіть якщо воно не є Promise. Це покриває змішаний sync/async код. .catch(next) приєднує обробник відхилень до ланцюжка, тому будь-яке відхилення викличе next(err). Express пропускає весь звичайний middleware і йде прямо до чотириаргументного error handler-а.
Можна також використовувати try/catch напряму, коли потрібно трансформувати помилку перед передачею:
app.get('/users', async (req, res, next) => {
try {
const users = await User.findAll();
res.json(users);
} catch (err) {
next(new DatabaseError('Query failed', err)); // кастомна форма помилки
}
});Використовуй wrapper для типових випадків. Використовуй try/catch + next(), коли потрібно змінити форму помилки.
Коли що використовувати
- asyncHandler wrapper: кожен async-обробник маршруту і async middleware
- try/catch + next(err): коли потрібно залогувати, трансформувати або доповнити помилку перед передачею
- Ні те, ні інше: синхронні обробники не потребують жодного з них
- Уникай:
.catch(), який мовчки ковтає помилки - це приховує баги
Альтернатива: express-async-errors
Якщо не хочеш вручну загортати кожен обробник, пакет express-async-errors патчить Express зсередини:
// npm install express-async-errors
require('express-async-errors'); // імпортуй один раз на початку app.js
// Тепер працює без wrapper-а
app.get('/users', async (req, res) => {
const users = await User.findAll();
res.json(users);
});Зручно для наявних кодових баз. Але це monkey-patch, і частина команд уникає такого підходу в продакшені. Обидва варіанти працюють.
Express 5 (нативна підтримка async)
Express 5 підтримує async-відхилення нативно. Без wrapper-а, без патчів:
npm install express@next// Express 5 - async помилки передаються автоматично
app.get('/users', async (req, res) => {
const users = await User.findAll();
res.json(users);
});Express 5 ще у стані RC на 2024 рік, але вже достатньо стабільний для нових проектів. Наявні проекти на Express 4 краще залишити з wrapper-підходом.
Типові помилки
Помилка 1: Не загортати async middleware
// ❌ НЕПРАВИЛЬНО - помилки middleware оминають error handler
app.use(async (req, res, next) => {
req.user = await User.findById(req.headers.authorization);
next();
});
// ✅ ПРАВИЛЬНО - middleware теж потребує wrapper-а
app.use(asyncHandler(async (req, res, next) => {
req.user = await User.findById(req.headers.authorization);
next();
}));Розробники часто загортають обробники маршрутів, але забувають про middleware. Та сама проблема, те саме рішення.
Помилка 2: Promise без await (fire-and-forget)
// ❌ НЕПРАВИЛЬНО - відхилення email не перехоплюється asyncHandler
app.get('/notify', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
sendEmail(user.email); // запущено без await - відхилення випадає
res.json(user);
}));
// ✅ ПРАВИЛЬНО - обробляй fire-and-forget явно
app.get('/notify', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
sendEmail(user.email).catch(err => {
logger.error('Email failed', err); // логуй, не падай
});
res.json(user);
}));asyncHandler перехоплює тільки ті Promise, які awaited або повернуті. Promise, який запущено без await, поза його зоною відповідальності.
Помилка 3: Розраховувати на нову версію Node.js
Навіть у Node 20 або 22, Express 4 не перехоплює async-відхилення. Node змінив поведінку для необроблених відхилень (крах проти попередження), але система middleware Express не змінилась. Wrapper все ще потрібен.
Помилка 4: Виклик next() після надсилання відповіді
// ❌ НЕПРАВИЛЬНО - відповідь вже надіслана, next() нічого не зробить
app.get('/users', asyncHandler(async (req, res, next) => {
const users = await User.findAll();
res.json(users);
next(); // немає ефекту
}));
// ✅ ПРАВИЛЬНО
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));Після res.json() відповідь надіслана. Не викликай next() після неї.
Де це зустрічається
- Express.js: кожен async-обробник і middleware потребує wrapper-а або try/catch
- Fastify: схожа проблема, потрібне явне оброблення помилок для async-обробників
- Koa: побудований навколо Promise з самого початку, async-помилки перехоплюються автоматично
- NestJS: використовує фільтри виключень (
@UseFilters()) для обробки async-помилок по всіх контролерах - Next.js API routes: відхилення Promise перехоплюються автоматично
- AWS Lambda: async-обробники мають повертати Promise; необроблені відхилення спричиняють збій виклику
Питання на співбесіді
Q: Чому Promise.resolve(fn(req, res, next)) відрізняється від fn(req, res, next).catch(next)?
A: Promise.resolve() гарантує, що результат завжди є Promise, навіть якщо fn повертає звичайне значення або нічого. Без нього, якщо передати не-async функцію, виклик .catch() на не-Promise значенні викине помилку. Обидва варіанти працюють для async-функцій, але Promise.resolve() робить wrapper безпечнішим для змішаного коду.
Q: Чи можна використовувати async/await в error middleware Express?
A: Так, але потрібно загорнути. Сигнатура error middleware - (err, req, res, next), чотири аргументи. Express розпізнає її за кількістю параметрів. Загортай так само: app.use(asyncHandler((err, req, res, next) => { ... })).
Q: Якщо і middleware, і обробник маршруту мають wrapper, і middleware викидає помилку - який wrapper її обробляє?
A: Wrapper middleware ловить її і викликає next(err). Express пропускає весь залишковий не-error middleware і маршрути і йде прямо до error middleware. Тільки один wrapper обробляє кожну конкретну помилку. Вони не ланцюжаться.
Q: Як протестувати, що помилки правильно передаються до error middleware?
A: Замокай базу даних щоб вона відхиляла запит, потім перевіряй що error handler отримав помилку. З Jest: jest.spyOn(User, 'findAll').mockRejectedValue(new Error('DB down')), потім перевіряй що error middleware була викликана. Для інтеграційних тестів добре підходить Supertest.
Q (senior): Що відбувається з Promise.all(), якщо один із Promise відхиляється всередині asyncHandler?
A: Promise.all() відхиляється з першою помилкою. Це відхилення виходить через await, async-функція відхиляється, і .catch(next) wrapper-а його перехоплює. Інші Promise продовжують виконуватись у фоні, але їхні результати ігноруються. Якщо потрібно їх скасувати, знадобиться AbortController. Error handler отримує рівно одну помилку.
Приклади
Базовий: asyncHandler у контролері
// utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// routes/users.js
const asyncHandler = require('../utils/asyncHandler');
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body); // помилка БД перехоплена автоматично
if (!user.email) throw new Error('Email required'); // теж перехоплено
res.status(201).json(user);
}));
// Один error handler для всіх маршрутів
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: err.message });
});І помилки бази даних, і помилки валідації потрапляють до error middleware. Логіку обробки помилок пишеш один раз, а не в кожному обробнику.
Середній: паралельні запити і timeout
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
function withTimeout(promise, ms = 5000) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
app.get('/dashboard', asyncHandler(async (req, res) => {
// Всі три запити паралельно, а не послідовно
const [users, posts, stats] = await Promise.all([
withTimeout(User.findAll(), 3000),
withTimeout(Post.findRecent(), 3000),
withTimeout(Stats.summary(), 3000),
]);
res.json({ users, posts, stats });
}));Послідовні await виконуються один за одним. Promise.all() запускає їх разом. На дашборді з трьома джерелами даних ця різниця відчутна.
Просунутий: fire-and-forget з правильною ізоляцією
app.get('/orders/:id/confirm', asyncHandler(async (req, res) => {
const order = await Order.findById(req.params.id);
await order.confirm(); // це має обов'язково пройти успішно
// Email - best-effort, його помилка не повинна ламати запит
sendConfirmationEmail(order).catch(err => {
logger.error('Confirmation email failed', { orderId: order.id, err });
// Не кидаємо далі - замовлення підтверджено незалежно від email
});
res.json({ confirmed: true, orderId: order.id });
}));asyncHandler wrapper перехоплює те, що async-функція await-ить або повертає. Promise, запущений без await, поза його зоною відповідальності. Обробляй такі явно. Це патерн, який я бачив як джерело збоїв у продакшені, коли команди вважали що wrapper ловить абсолютно все.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.