Асинхронные ошибки в JavaScript: как не потерять исключения и предотвратить утечки памяти

Представьте: ваше приложение падает в продакшене с сообщением UnhandledPromiseRejection, но в локальной среде всё работает идеально. В логин-формах иногда «зависают» кнопки, а после навигации в React-приложении возникают необъяснимые сетевые запросы. Всё это – последствия некорректной обработки асинхронных операций. Разберём, почему стандартные try/catch бессильны против плавающих багов и как создать надёжную систему обработки ошибок.

Почему промисы протекают

Типичная ошибка в Express-роутере:

javascript
app.post('/api/data', async (req, res) => {
  const data = await fetchExternalAPI(); // Может упасть с исключением
  res.json(data);
});

При сбое fetchExternalAPI клиент никогда не получит ответа – обработчик Express замрёт. Решение выглядит очевидным:

javascript
app.post('/api/data', async (req, res) => {
  try {
    const data = await fetchExternalAPI();
    res.json(data);
  } catch (error) {
    res.status(500).send('Error');
  }
});

Но настоящие проблемы начинаются, когда мы комбинируем асинхронные операции. Пример из React-компонента:

javascript
useEffect(() => {
  fetch('/data')
    .then(response => response.json())
    .then(data => setState(data));
}, []);

Если компонент размонтируется до завершения запроса, попытка обновить setState вызовет ошибку. Браузер проигнорирует её, но утечка памяти останется.

Паттерны атомарной отмены

Рецепт – AbortController для управления жизненным циклом операций:

javascript
useEffect(() => {
  const controller = new AbortController();
  
  const loadData = async () => {
    try {
      const response = await fetch('/data', {
        signal: controller.signal
      });
      const data = await response.json();
      if (!controller.signal.aborted) {
        setState(data);
      }
    } catch (error) {
      if (error.name !== 'AbortError') {
        logError(error);
      }
    }
  };

  loadData();
  return () => controller.abort();
}, []);

Ключевые моменты:

  1. Проверка aborted перед обновлением состояния
  2. Фильтрация AbortError в catch
  3. Интеграция с fetch, axios и другими библиотеками

Контроль над параллелизмом

Promise.all прерывается при первой ошибке – не всегда желаемое поведение. Альтернатива:

javascript
const [users, posts] = await Promise.all([
  fetch('/users').then(p => p.json()),
  fetch('/posts').then(p => p.json())
]);

В реальности лучше использовать Promise.allSettled с пост-обработкой:

javascript
const results = await Promise.allSettled([
  fetchUsers(),
  fetchPosts()
]);

const errors = results
  .filter(r => r.status === 'rejected')
  .map(r => r.reason);

if (errors.length > 0) {
  await sendErrorReport(errors);
}

const successfulData = results
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

Интеграция с системами мониторинга

Даже идеальная обработка ошибок не гарантирует их видимости. Настройте глобальные обработчики:

javascript
// Node.js
process.on('unhandledRejection', (reason, promise) => {
  Sentry.captureException(reason);
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

// Браузер
window.addEventListener('unhandledrejection', event => {
  event.preventDefault();
  Sentry.captureException(event.reason);
});

Но помните – глобальный обработчик должен быть крайней линией защиты, а не заменой локальным try/catch.

Когда async/await не панацея

Синтаксис async/await создаёт иллюзию синхронного кода, но скрывает важные нюансы. Пример опасного кода:

javascript
async function processBatch() {
  const data = await loadData();
  await Promise.all(data.map(async item => {
    const details = await loadDetails(item.id); // Параллелизм?
    await saveToDB(details); // Последовательные записи!
  }));
}

Фикс через явное разделение задач:

javascript
async function processBatch() {
  const data = await loadData();
  const detailPromises = data.map(item => 
    loadDetails(item.id)
      .then(details => saveToDB(details))
  );
  
  await Promise.all(detailPromises);
}

Заключение

Основные принципы обработки асинхронных ошибок:

  1. Все асинхронные операции должны иметь явный механизм отмены
  2. Глобальные обработчики – для мониторинга, не для логики приложения
  3. Тестируйте сценарии с искусственными задержками и принудительными прерываниям
  4. Бенчмаркируйте память при частых асинхронных операциях

Чтобы полностью контролировать асинхронный код, перестаньте доверять ему. Каждое await – потенциальная точка разрыва потока выполнения. Относитесь к цепочкам промисов как к системным ресурсам: явно создавайте, отслеживайте и обязательно освобождайте.

text