Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Асинхронність

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Promise - об'єкт-обіцянка результату, який з'явиться пізніше: відповідь сервера, прочитаний файл, таймер. Замість того щоб передавати колбек, функція одразу повертає Promise, а результат на нього «підписують».

Три стани:

  • pending - очікування, результату ще немає;
  • fulfilled - виконано успішно, є значення;
  • rejected - відхилено, є причина (зазвичай об'єкт Error).

Перехід з pending відбувається один раз і назавжди: виконаний Promise не стане відхиленим.

fetch('/api/user')
  .then((response) => response.json())
  .then((user) => console.log(user.name))
  .catch((error) => console.error('Не вдалося завантажити', error))
  .finally(() => hideSpinner());

Що варто знати:

  • then() повертає новий Promise, тому виклики можна з'єднувати в ланцюжок. Значення, повернене з then, стає результатом наступного кроку.
  • Один catch() наприкінці ловить помилку з будь-якого попереднього кроку.
  • Promise без обробника помилки, який відхилився, дає unhandledrejection - у Node.js це за замовчуванням завершує процес.
  • fetch відхиляє Promise лише при мережевій помилці. Відповідь 404 чи 500 - це виконаний Promise з response.ok === false.

Докладніше в документації: Promise

По суті нічим: async/await - зручніший запис тих самих Promise. Асинхронний код читається зверху вниз, як синхронний.

// then()
function loadUser(id) {
  return fetch(`/api/users/${id}`)
    .then((r) => r.json())
    .then((user) => user.name);
}

// async/await
async function loadUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user.name;
}

Правила:

  • async-функція завжди повертає Promise. return 5 у ній - це Promise, що виконається з 5.
  • await чекає Promise і повертає його значення. Якщо Promise відхилено, await кидає виняток.
  • Помилки ловлять звичайним try/catch.
try {
  const user = await loadUser(1);
} catch (error) {
  showError(error.message);
}

Пастка - зайва послідовність:

const user = await loadUser();     // чекаємо
const posts = await loadPosts();   // і лише тоді починаємо друге

Якщо запити незалежні, їх запускають разом: const [user, posts] = await Promise.all([loadUser(), loadPosts()]);.

await на верхньому рівні (поза функцією) працює в ES-модулях.

Докладніше в документації: async function

setTimeout(fn, 0) означає не «виконай зараз», а «постав у чергу, щойно буде можливість». JavaScript у браузері виконується в одному потоці: поки працює поточний код, ніщо інше не запуститься.

console.log('1');
setTimeout(() => console.log('3'), 0);
Promise.resolve().then(() => console.log('2б'));
console.log('2');
// 1, 2, 2б, 3

Порядок такий:

  1. виконується весь синхронний код до кінця;
  2. виконуються всі мікрозадачі (колбеки Promise, queueMicrotask);
  3. браузер може перемалювати сторінку;
  4. береться наступна макрозадача - зокрема колбек таймера.

Тому колбек Promise спрацьовує раніше за таймер з нульовою затримкою.

Затримка - мінімальна, а не точна. setTimeout(fn, 100) гарантує лише, що колбек запуститься не раніше ніж за 100 мс. Якщо головний потік зайнятий важким обчисленням 2 секунди, таймер спрацює через 2 секунди.

Інші обмеження:

  • вкладені таймери після п'яти рівнів отримують мінімальну затримку 4 мс;
  • у фонових вкладках браузер пригальмовує таймери до разу на секунду і рідше - не можна покладатися на них для точного відліку часу;
  • для анімацій є requestAnimationFrame, синхронізований з перемальовуванням екрана.

Навіщо setTimeout(fn, 0) на практиці: відкласти роботу до моменту, коли поточна подія оброблена й DOM оновлено, або розбити довгу роботу на шматки, даючи браузеру перемальовувати сторінку між ними.

Докладніше в документації: setTimeout()

Колбек (функція зворотного виклику) - функція, яку передають іншій функції, щоб та викликала її пізніше:

button.addEventListener('click', () => console.log('клік'));
[1, 2, 3].map((n) => n * 2);   // теж колбек, але синхронний

До появи Promise асинхронний код писали лише колбеками: «зроби запит і, коли буде відповідь, виклич цю функцію».

Пекло колбеків з'являється, коли кроки залежать один від одного:

getUser(id, (error, user) => {
  if (error) return showError(error);
  getOrders(user.id, (error, orders) => {
    if (error) return showError(error);
    getInvoice(orders[0].id, (error, invoice) => {
      if (error) return showError(error);
      render(invoice);
    });
  });
});

Проблеми:

  • код росте вправо і погано читається;
  • помилку треба перевіряти на кожному рівні окремо - легко пропустити;
  • немає простого способу запустити кілька операцій паралельно й дочекатися всіх;
  • функція може викликати колбек двічі чи жодного разу - і нічого вас від цього не захистить.

Те саме з Promise і async/await:

try {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  render(await getInvoice(orders[0].id));
} catch (error) {
  showError(error);
}

Код читається зверху вниз, одна обробка помилок на всі кроки, а Promise гарантовано виконується чи відхиляється рівно один раз.

Колбеки нікуди не зникли: обробники подій, методи масивів, setTimeout - це колбеки, і для них вони доречні. Від колбеків відмовилися саме для одноразових асинхронних результатів.

Докладніше в документації: Функція зворотного виклику

JavaScript виконує код в одному потоці. Асинхронні дії (таймери, мережа, події) виконує середовище - браузер чи Node.js, а колбеки, що мають спрацювати після них, стають у черги. Event loop бере з черг наступне завдання, коли стек викликів порожній.

Дві черги з різним пріоритетом:

  • Макрозадачі (tasks): setTimeout, setInterval, події користувача, повідомлення з мережі.
  • Мікрозадачі (microtasks): колбеки Promise (then, продовження після await), queueMicrotask, MutationObserver.

Порядок: виконати одну макрозадачу → виконати всі мікрозадачі, що накопичилися (включно з тими, що додалися під час виконання) → (у браузері) відмалювати сторінку → наступна макрозадача.

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2

setTimeout(fn, 0) не означає «одразу»: це макрозадача, і вона чекає, поки виконаються всі мікрозадачі.

Практичні наслідки:

  • Довгий синхронний код (великий цикл, важкий JSON.parse) блокує все: кліки, анімацію, рендер. Важку роботу ділять на частини або виносять у Web Worker.
  • Нескінченний ланцюжок мікрозадач теж заморожує сторінку: рендер чекає, поки черга мікрозадач спорожніє.

Докладніше в документації: Модель виконання JavaScript

Усі чотири приймають масив Promise і запускаються паралельно, але по-різному вирішують, коли й чим завершитися:

  • Promise.all - чекає всі. Результат - масив значень у тому ж порядку. Перша ж помилка відхиляє весь результат (інші Promise продовжують виконуватися, але їхні результати губляться).
  • Promise.allSettled - чекає всі, ніколи не відхиляється. Результат - масив об'єктів {status: 'fulfilled', value} або {status: 'rejected', reason}.
  • Promise.race - результат першого, що завершився, хай успіхом, хай помилкою.
  • Promise.any - перший успішний. Відхиляється з AggregateError, лише якщо відхилено всі.
// Усе потрібне для сторінки - інакше показати помилку
const [user, settings] = await Promise.all([getUser(), getSettings()]);

// Розіслати сповіщення й порахувати, скільки не дійшло
const results = await Promise.allSettled(recipients.map(send));
const failed = results.filter((r) => r.status === 'rejected').length;

// Тайм-аут
const data = await Promise.race([fetchData(), sleep(5000).then(() => { throw new Error('timeout'); })]);

// Перше доступне дзеркало
const file = await Promise.any(mirrors.map((url) => fetch(url)));

Нюанс race для тайм-ауту: «програлий» запит не скасовується, він продовжує виконуватися. Для справжнього скасування потрібен AbortController (у fetch - AbortSignal.timeout(5000)).

Докладніше в документації: Promise.all

Старі API (події, таймери, бібліотеки з колбеками) зручно «загорнути» в Promise, щоб використовувати з await. Це називають промісифікацією.

Класичний спосіб - конструктор new Promise:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Не вдалося завантажити ${src}`));
    image.src = src;
  });
}

await sleep(500);
const image = await loadImage('/logo.png');

Правила:

  • resolve і reject діють лише перший раз - повторні виклики ігноруються;
  • виняток, кинутий синхронно всередині функції-виконавця, автоматично відхиляє Promise;
  • не загортайте в new Promise те, що вже повертає Promise, - це зайвий шар і місце для втрачених помилок.

Promise.withResolvers() (ES2024) повертає Promise разом з його resolve і reject назовні:

const { promise, resolve, reject } = Promise.withResolvers();

Це зручно, коли виконання відбувається не в одному колбеку, а десь пізніше - наприклад, чекаємо на відповідь у WebSocket за ідентифікатором запиту:

const pending = new Map();

function request(socket, payload) {
  const id = crypto.randomUUID();
  const { promise, resolve, reject } = Promise.withResolvers();
  pending.set(id, { resolve, reject });
  socket.send(JSON.stringify({ id, ...payload }));
  return promise;
}

socket.onmessage = (event) => {
  const { id, result, error } = JSON.parse(event.data);
  const handlers = pending.get(id);
  pending.delete(id);
  error ? handlers?.reject(new Error(error)) : handlers?.resolve(result);
};

Раніше для цього писали той самий шаблон вручну, зберігаючи resolve у зовнішні змінні з конструктора.

У Node.js для функцій зі стилем (error, result) є готовий util.promisify, а більшість модулів мають Promise-версії (node:fs/promises).

Докладніше в документації: Promise.withResolvers()

for await...of перебирає послідовність значень, що надходять асинхронно: кожен крок циклу чекає на наступне значення.

Асинхронний генератор - найпростіший спосіб таку послідовність створити. Наприклад, обхід API з пагінацією:

async function* fetchAllPages(url) {
  let next = url;
  while (next) {
    const response = await fetch(next);
    const page = await response.json();
    yield* page.data;          // віддати елементи сторінки по одному
    next = page.links.next;    // колекція API Resource у Laravel кладе сюди URL наступної сторінки
  }
}

for await (const user of fetchAllPages('/api/users')) {
  console.log(user.name);
  if (user.id === 42) break;   // решта сторінок навіть не буде завантажена
}

Переваги перед «завантажити все в масив»:

  • лінивість - наступна сторінка запитується лише тоді, коли попередню оброблено;
  • пам'ять - не треба тримати весь набір одночасно;
  • break чи return з циклу зупиняє генератор, і зайві запити не робляться.

Де ще зустрічається:

  • потоки даних: ReadableStream у Node.js, Chrome і Firefox можна перебирати через for await (Safari поки ні - там читають через getReader()):
const response = await fetch('/export.csv');
const decoder = new TextDecoder();
for await (const chunk of response.body) {
  process(decoder.decode(chunk, { stream: true }));
}
  • читання файлів построково в Node.js (readline), події, черги повідомлень.

for await над масивом Promise теж працює, але виконує їх у порядку масиву, а не в порядку завершення, і помилки обробляє незручно: for await (const r of [p1, p2, p3]). Якщо Promise вже запущені, краще Promise.all чи Promise.allSettled.

Array.fromAsync() (ES2024) збирає асинхронну послідовність у масив: const users = await Array.fromAsync(fetchAllPages('/api/users'));.

Докладніше в документації: for await...of

Сам Promise скасувати не можна - лише проігнорувати його результат. Справжнє скасування роблять через AbortController: операція отримує signal і сама стежить, чи його не скасували.

const controller = new AbortController();

fetch('/api/search?q=laravel', { signal: controller.signal })
  .then((r) => r.json())
  .then(render)
  .catch((error) => {
    if (error.name === 'AbortError') return; // скасовано свідомо - не помилка
    showError(error);
  });

controller.abort(); // запит перервано, з'єднання закрито

Типові сценарії:

  • Пошук під час набору: кожна нова літера скасовує попередній запит, щоб старіша відповідь не перезаписала новішу (класична гонка).
  • Компонент зник (React-ефект, Vue onUnmounted) - скасувати його запити й підписки.
  • Тайм-аут: fetch(url, { signal: AbortSignal.timeout(5000) }).
  • Кілька причин скасування одразу: AbortSignal.any([userSignal, AbortSignal.timeout(5000)]).

Сигнал працює не лише з fetch: addEventListener(type, fn, { signal }) знімає обробник при скасуванні, а у власних функціях можна перевіряти signal.aborted чи слухати подію abort:

async function processAll(items, signal) {
  for (const item of items) {
    signal.throwIfAborted();
    await process(item);
  }
}

Важливо: скасування на клієнті не зупиняє вже розпочату роботу на сервері. Якщо запит змінює дані, сервер міг їх уже змінити.

Докладніше в документації: AbortController

forEach викликає колбек і ігнорує те, що він повертає. async-колбек повертає Promise, який ніхто не чекає: цикл завершується одразу, помилки губляться.

items.forEach(async (item) => { await save(item); });
console.log('готово'); // виведеться ДО того, як щось збережеться

Правильні варіанти залежно від задачі:

// Послідовно, по одному
for (const item of items) {
  await save(item);
}

// Усі паралельно
await Promise.all(items.map((item) => save(item)));

Проблема «всіх паралельно»: тисяча елементів - тисяча одночасних запитів. API почне відповідати 429, браузер обмежить кількість з'єднань, база отримає піку навантаження.

Обмежена паралельність - не більше N одночасно:

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const index = next++;
      results[index] = await fn(items[index], index);
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const saved = await mapLimit(items, 5, save);

Тут next++ безпечний, бо JavaScript однопотоковий: між читанням і збільшенням лічильника інший «воркер» виконатися не може.

На практиці часто беруть готові бібліотеки (p-limit, p-map), які додають ще й обробку помилок і скасування.

Докладніше в документації: Array.prototype.forEach

fetch за замовчуванням не має тайм-ауту: запит до сервера, що завис, може «висіти» хвилинами. А тимчасові збої мережі чи відповідь 503 часто зникають після повтору.

Тайм-аут через AbortSignal.timeout():

try {
  const response = await fetch('/api/report', { signal: AbortSignal.timeout(5000) });
} catch (error) {
  if (error.name === 'TimeoutError') {
    showError('Сервер не відповів за 5 секунд');
  }
}

При тайм-ауті Promise відхиляється з TimeoutError, а при ручному скасуванні через AbortController - з AbortError. Це дає змогу розрізнити причини.

Поєднати тайм-аут і ручне скасування (користувач пішов зі сторінки) - AbortSignal.any():

const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);

Повтор з експоненційною затримкою і випадковим розкидом:

async function fetchWithRetry(url, options = {}, { retries = 3, baseDelay = 300 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      const response = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
      if (response.status < 500 && response.status !== 429) {
        return response;                 // успіх або помилка клієнта - не повторюємо
      }
      if (attempt >= retries) return response;
    } catch (error) {
      if (attempt >= retries) throw error;
    }
    const delay = baseDelay * 2 ** attempt * (0.5 + Math.random());
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

Що важливо:

  • повторювати лише те, що має сенс повторювати: мережеві помилки, тайм-аути, 502/503/504, 429. Помилки 4xx (валідація, 403, 404) повтор не виправить;
  • ідемпотентність: GET повторювати безпечно. POST на створення замовлення - ні: перша спроба могла дійти до сервера, а загубилася лише відповідь. Для таких запитів потрібен ключ ідемпотентності, який сервер перевіряє;
  • розкид затримки (jitter) потрібен, щоб тисячі клієнтів після збою не повторювали запити синхронно й не «добили» сервер, що відновлюється;
  • Retry-After у відповіді 429/503 варто поважати замість власної затримки;
  • загальний бюджет часу важливіший за кількість спроб: користувач не чекатиме хвилину на три повтори з тайм-аутом по 20 секунд.

Докладніше в документації: AbortSignal.timeout()

Поки JavaScript виконується, браузер не може обробити клік, натискання клавіші чи перемалювати сторінку. Довга задача (long task) - це шматок роботи головного потоку понад 50 мс. Користувач бачить «завислий» інтерфейс, а метрика INP (Interaction to Next Paint) погіршується.

// обробка 50 000 рядків одним циклом - інтерфейс завмирає на секунду
rows.forEach((row) => renderRow(row));

Async-функція сама по собі не допомагає. await всередині циклу над синхронною роботою не віддає керування браузеру надовго: мікрозадачі виконуються одна за одною до порожньої черги, і браузер між ними не малює.

Рішення 1 - віддавати керування між шматками:

async function processInChunks(items, handle) {
  let deadline = performance.now() + 40;
  for (const item of items) {
    handle(item);
    if (performance.now() > deadline) {
      await yieldToMain();
      deadline = performance.now() + 40;
    }
  }
}

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();              // продовження отримує пріоритет
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

scheduler.yield() віддає керування браузеру, але ставить продовження попереду інших задач, тож робота не «загубиться» в черзі. Він підтримується не всюди, тому потрібен запасний варіант через setTimeout.

Рішення 2 - винести обчислення з головного потоку у Web Worker. Підходить для чистих обчислень (парсинг великого файлу, стиснення, криптографія), але воркер не має доступу до DOM.

Рішення 3 - не робити зайвої роботи:

  • віртуалізація довгих списків - рендерити лише видимі 30 рядків замість 50 000;
  • requestIdleCallback для некритичної роботи (аналітика, попереднє завантаження) - коли браузеру нічим зайнятися;
  • debounce для обробників, що спрацьовують на кожне натискання клавіші.

Як знайти довгі задачі: вкладка Performance у DevTools (позначені червоним), або програмно:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) console.warn('Long task', entry.duration);
}).observe({ type: 'longtask', buffered: true });

Докладніше в документації: Scheduler.yield()