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

Senior: питання на співбесіді з теми «Асинхронність»

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

4 питання

Сам 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()