Коротка відповідь. Promise – це об'єкт-обіцянка результату, який перебуває в одному з трьох станів: pending, fulfilled або rejected. async/await – синтаксис поверх промісів, не окремий механізм. На співбесідах найчастіше валяться не на визначеннях, а на трьох речах: паралельність (Promise.all проти послідовних await), обробка помилок (де саме спрацює catch) і поведінка проміса в комбінації з циклами й map. Розберімо кожну.

Стани проміса за одну хвилину

Проміс створюється в стані pending і рівно один раз переходить у fulfilled (зі значенням) або rejected (з причиною). Повторно змінити стан неможливо – другий виклик resolve чи reject ігнорується. await розгортає fulfilled-значення або кидає rejected-причину як виняток. Базу добре пояснює гайд MDN про використання промісів; тут зосередимось на практиці.

Послідовно чи паралельно: головне питання співбесіди

Класична помилка, яку інтерв'юери шукають спеціально:

// Послідовно: друга «запит» чекає першу. Разом ~2 сек
const user = await fetchUser(id);       // ~1 сек
const orders = await fetchOrders(id);   // ~1 сек

Якщо запити незалежні – запускай їх одночасно:

// Паралельно: разом ~1 сек
const [user, orders] = await Promise.all([
  fetchUser(id),
  fetchOrders(id),
]);

Коли що використовувати:

  • Promise.all – потрібні всі результати, і будь-яка помилка робить решту безглуздою. Падає з першим reject.
  • Promise.allSettled – потрібні всі результати, але часткова відмова прийнятна (наприклад, віджети дашборда). Ніколи не reject-иться, повертає масив { status, value | reason }.
  • Promise.race – потрібен перший результат, байдуже який: типово таймаут проти запиту.
const results = await Promise.allSettled([fetchUser(id), fetchOrders(id)]);
for (const result of results) {
  if (result.status === 'fulfilled') {
    render(result.value);
  } else {
    showWidgetError(result.reason);
  }
}

На співбесіді сильна відповідь – не «завжди Promise.all», а «залежить від того, чи роблять часткові дані інтерфейс корисним».

Пастки try/catch

Пастка 1 – повернення без await. catch нижче не спрацює, бо проміс покидає функцію ще pending:

async function loadUser(id: string) {
  try {
    return fetchUser(id);        // немає await!
  } catch (error) {
    log(error);                  // сюди НЕ потрапимо при reject
    throw error;
  }
}

Правильно: return await fetchUser(id); – тоді reject перехопиться в цьому try/catch.

Пастка 2 – async у map. array.map(async ...) повертає масив промісів, а не результатів:

const ids = ['1', '2', '3'];

// Помилка: users – це Promise<User>[]
const users = ids.map(async (id) => fetchUser(id));

// Правильно: дочекатися всіх
const loaded = await Promise.all(ids.map((id) => fetchUser(id)));

Бонусне запитання інтерв'юера: «а якщо треба строго по черзі?» Відповідь – цикл for...of з await усередині, і варто вміти пояснити, чому це рідко потрібно (повільніше) і коли таки потрібно (rate limit, залежність наступного запиту від попереднього).

Пастка 3 – проміс стартує при створенні. await не «запускає» проміс – він уже виконується з моменту виклику функції. Тому такий код таки паралельний:

const userPromise = fetchUser(id);     // старт одразу
const ordersPromise = fetchOrders(id); // старт одразу
const user = await userPromise;
const orders = await ordersPromise;

Строго послідовно: коли цикл кращий за Promise.all

Інтерв'юери люблять продовження: «а тепер зроби так, щоб запити йшли по одному». Відповідь – звичайний цикл з await:

async function loadInOrder(ids: string[]): Promise<User[]> {
  const users: User[] = [];
  for (const id of ids) {
    users.push(await fetchUser(id)); // наступний стартує після попереднього
  }
  return users;
}

Важливо вміти назвати, коли це виправдано: зовнішнє API з лімітом запитів, кожен наступний запит залежить від попереднього результату, або запис у ресурс, що не терпить конкурентних змін. У всіх інших випадках послідовний цикл – просто повільніша версія Promise.all, і сказати це вголос теж частина правильної відповіді.

Компроміс між «усе одразу» і «по одному» – обмежена конкурентність: розбити список на порції.

async function loadInBatches(ids: string[], batchSize = 5): Promise<User[]> {
  const result: User[] = [];
  for (let i = 0; i < ids.length; i += batchSize) {
    const batch = ids.slice(i, i + batchSize);
    result.push(...(await Promise.all(batch.map(fetchUser))));
  }
  return result;
}

Це вже рівень Middle: задача та сама, але відповідь враховує реальні обмеження бекенда.

Повторні спроби з паузою

Ще один практичний патерн, який просять написати «з голови», – retry з експоненційною затримкою:

async function withRetry<T>(
  action: () => Promise<T>,
  attempts = 3,
  baseDelayMs = 200
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await action();
    } catch (error) {
      lastError = error;
      const delay = baseDelayMs * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

const user = await withRetry(() => fetchUser('1'));

Тут зібрано одразу кілька тем зі співбесіди: generic-типізація, «промісифікація» setTimeout і акуратне прокидання останньої помилки після вичерпання спроб. Якщо додаси уточнення «повторювати варто лише мережеві/5xx-помилки, а не 400-ті» – відповідь стане production-рівня.

Скасування: AbortController

Промісам не можна «наказати зупинитись», але запит можна скасувати кооперативно:

const controller = new AbortController();

const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch('/api/search?q=react', {
    signal: controller.signal,
  });
  return await response.json();
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    return null; // скасовано таймаутом – не показуємо помилку
  }
  throw error;
} finally {
  clearTimeout(timeoutId);
}

Типовий фронтенд-кейс: скасовувати попередній запит пошуку, коли користувач продовжив вводити текст. Це часта задача на React live coding.

Три задачі «що виведе код»

Задача 1.

console.log('a');
Promise.resolve().then(() => console.log('b'));
console.log('c');
// a, c, b

Колбек .then – мікрозадача: виконається після синхронного коду. Чому саме так – детально в статті про Event Loop.

Задача 2.

async function run() {
  try {
    await Promise.reject(new Error('boom'));
  } catch (e) {
    console.log('caught:', e.message);
  }
}
run();
// caught: boom

await перетворює reject на звичайний виняток – тому try/catch працює.

Задача 3.

const p = new Promise((resolve) => {
  resolve(1);
  resolve(2);
});
p.then((v) => console.log(v));
// 1

Стан фіксується першим resolve; другий ігнорується без помилки.

Як тренуватись

Візьми будь-яку функцію з запитами зі свого пет-проєкту і постав собі три питання: що тут можна розпаралелити; де саме перехопиться помилка; що станеться, якщо компонент розмонтується до відповіді. Потім перевір відповіді кодом. Ширший план підготовки – у гайді з JavaScript-співбесіди і в добірці live-coding задач.