Коротка відповідь. JavaScript виконує код в одному потоці. Event loop – механізм, який після завершення синхронного коду по черзі бере відкладені задачі: спочатку ВСІ мікрозадачі (колбеки промісів, queueMicrotask), потім одну макрозадачу (setTimeout, події, I/O) – і так по колу. Саме тому Promise.then завжди виконається раніше за setTimeout(…, 0). Нижче – механіка по кроках і три задачі, які реально ставлять на співбесідах.

Три складові, які треба назвати

  1. Call stack – стек викликів. Синхронний код виконується тут і зараз; поки стек не порожній, нічого «відкладене» не запуститься.
  2. Черга макрозадач (task queue) – колбеки setTimeout/setInterval, обробники подій, I/O. За одну ітерацію циклу виконується одна макрозадача.
  3. Черга мікрозадач (microtask queue) – колбеки .then/.catch/.finally, продовження після await, queueMicrotask. Після кожної макрозадачі (і після початкового синхронного коду) виконуються всі накопичені мікрозадачі, включно з тими, що додалися під час виконання.

Алгоритм одним реченням для співбесіди: «виконати синхронний код → спорожнити всі мікрозадачі → взяти одну макрозадачу → знову всі мікрозадачі → повторювати».

Чому Promise.then раніше за setTimeout

console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('end');
// start, end, promise, timeout

Розбір по кроках:

  1. 'start' – синхронно, виконується одразу.
  2. setTimeout реєструє колбек у черзі макрозадач (навіть із затримкою 0).
  3. .then реєструє колбек у черзі мікрозадач.
  4. 'end' – синхронний код закінчився, стек порожній.
  5. Event loop спершу спорожнює мікрозадачі → 'promise'.
  6. Лише потім бере макрозадачу → 'timeout'.

Типова неправильна відповідь: «setTimeout з нулем виконається одразу». Ні: нуль означає «не раніше ніж», а не «зараз»; колбек усе одно чекає своєї черги після мікрозадач.

Задача 2: async/await – це теж мікрозадачі

async function foo() {
  console.log('foo start');
  await null;
  console.log('foo after await');
}

console.log('script start');
foo();
console.log('script end');
// script start, foo start, script end, foo after await

Ключовий момент: тіло async-функції до першого await виконується синхронно. Сам await розрізає функцію: усе, що після нього, стає мікрозадачею. Тому 'foo after await' виводиться після 'script end', але без участі жодного таймера.

Типова неправильна відповідь: «async-функція вся виконується асинхронно». Ні – асинхронним стає лише продовження після await.

Задача 3: мікрозадачі попереду навіть «старших» таймерів

setTimeout(() => console.log('timeout 1'), 0);

Promise.resolve()
  .then(() => console.log('then 1'))
  .then(() => console.log('then 2'));

setTimeout(() => console.log('timeout 2'), 0);
// then 1, then 2, timeout 1, timeout 2

Обидва таймери зареєстровані раніше, ніж виконався перший .then, але це не має значення: перед першою макрозадачею event loop виконує всі мікрозадачі. Другий .then додається в чергу мікрозадач під час виконання першого – і теж встигає до таймерів, бо черга мікрозадач спорожнюється до кінця.

Небезпечний наслідок для практики: нескінченний ланцюжок мікрозадач (наприклад, рекурсивний queueMicrotask) повністю заблокує таймери й рендеринг – черга макрозадач просто ніколи не отримає хід.

Як це виглядає в браузері і навіщо це знати

Подія кліку, відповідь fetch, таймер – усе проходить через ці черги. Практичні наслідки, про які варто сказати на співбесіді:

  • Довга синхронна операція «підвішує» інтерфейс: поки стек зайнятий, жоден обробник події не виконається.
  • setTimeout(fn, 0) – легальний спосіб відкласти роботу «після поточного кадру подій», але не гарантія часу.
  • Порядок виводу визначається типом черги, а не порядком рядків у коді.

Довідкове першоджерело з термінологією – розділ про concurrency model на MDN.

Задача 4 (комбінована): await + setTimeout разом

Фінальний рівень – коли в одному фрагменті і async-функція, і таймер, і ланцюжок then:

async function main() {
  console.log('1');
  setTimeout(() => console.log('2'), 0);
  await Promise.resolve();
  console.log('3');
  Promise.resolve().then(() => console.log('4'));
  console.log('5');
}
main();
console.log('6');
// 1, 6, 3, 5, 4, 2

Розбір: '1' синхронно; таймер у макрочергу; await розрізає функцію – решта стає мікрозадачею; тому далі синхронний '6'. Потім мікрозадача: '3', реєстрація then ('4' – у кінець мікрочерги), '5'. Мікрочерга ще не порожня → '4'. І лише тепер макрозадача → '2'. Якщо ти можеш пояснити цей приклад без запинки – тема закрита.

А де тут рендеринг

У браузері між макрозадачами рушій може виконати рендеринг (перерахунок стилів, layout, paint). Практичний висновок для співбесіди з frontend-ухилом: важка синхронна робота або нескінченні мікрозадачі відкладають не лише таймери, а й відмалювання – сторінка «замерзає». Тому великі обчислення ріжуть на шматки (через setTimeout/queueMicrotask свідомо або Web Workers), а анімації прив'язують до requestAnimationFrame, який викликається перед кадром рендерингу.

Типові неправильні відповіді кандидатів

  1. «JavaScript багатопотоковий, бо є асинхронність». Ні: потік виконання один; асинхронність – це черги, а не паралельність.
  2. «Мікро- і макрозадачі – одна черга з пріоритетами». Ні: це дві різні черги з різними правилами спорожнення (усі проти однієї).
  3. «await зупиняє весь JavaScript». Ні: він призупиняє лише поточну async-функцію; решта коду виконується далі.
  4. «Порядок setTimeout(0) і Promise залежить від браузера». Ні: порядок визначений специфікацією і стабільний.

Швидка самоперевірка перед співбесідою

Відповідай уголос, без підглядання:

  1. Скільки макрозадач виконується за одну ітерацію циклу? (Одна.)
  2. Скільки мікрозадач? (Усі в черзі, включно з доданими під час виконання.)
  3. Куди потрапляє код після await? (У чергу мікрозадач.)
  4. Чи може setTimeout(fn, 0) виконатися раніше за .then, зареєстрований пізніше? (Ні – мікрозадачі завжди попереду наступної макрозадачі.)

Якщо на будь-якому пункті завагався – повернись до відповідної задачі вище й прожени її в консолі.

Як закріпити

Візьми будь-який приклад із цієї статті, зміни його (додай другий await, вклади setTimeout у then) і спочатку запиши очікуваний вивід на папері, потім запусти. Розрив між прогнозом і реальністю – твій список для повторення. Ця сама навичка «прогнозувати виконання» перевіряється й у задачах із добірки live coding, а системну підготовку зручно будувати за планом зі статті про підготовку за 30 днів. Асинхронні патерни поверх цієї механіки – в окремому розборі async/await і Promise.