Виконання JavaScript та event loop
Зміст
Коли автотест «клікає занадто рано», коли expect спрацьовує до того, як зʼявився елемент, коли той самий тест то зелений, то червоний — за цим майже завжди стоїть нерозуміння того, як браузер виконує JavaScript. Ця модель виконання пояснює, чому інтерфейс не «застигає» під час завантаження даних, чому setTimeout(fn, 0) не миттєвий і чому очікування в тестах — не милиця, а прямий наслідок асинхронної природи вебу.
Розберемо механізм по шарах: від одного потоку і стека викликів до черг задач, промісів і практичних висновків для написання стабільних тестів.
Однопотоковість JavaScript
JavaScript у браузері виконується в одному потоці (single-threaded). Це означає, що в кожен момент часу рушій виконує рівно один фрагмент коду — не два, не десять. Немає паралельного виконання твого коду, немає двох функцій, які працюють «одночасно» і борються за одну змінну так, як це буває в багатопотоковій Java чи C++.
Розберімося з «чому». Мова створювалась для маніпуляцій з інтерфейсом сторінки — з DOM (Document Object Model). Якби два потоки могли одночасно змінювати одне й те саме дерево елементів, знадобилися б блокування (locks), і будь-яка помилка розробника вела б до дедлоків і гонок прямо у відмальовуванні сторінки. Один потік прибирає цей клас проблем: порядок операцій над DOM у межах одного синхронного блоку завжди детермінований.
Тут важливо не сплутати два поняття. Однопотоковий — це рушій виконання JS-коду. Але сам браузер (чи Node.js) — багатопотоковий: мережеві запити, таймери, читання файлів, декодування зображень виконують окремі системні механізми поза потоком JS. Тобто твій код працює в один потік, а «важку» роботу (чекання відповіді сервера) браузер делегує назовні й повертає результат тоді, коли потік JS звільниться. Саме на цьому стику й народжується асинхронність.
Call stack — стек викликів
Стек викликів (call stack) — це структура, у якій рушій відстежує, яка функція зараз виконується і хто її викликав. Працює за принципом LIFO (last in, first out): останній зайшов — перший вийшов.
Коли викликається функція, на вершину стека кладеться її кадр (frame) — запис про виклик. Коли функція завершується (return або кінець тіла), кадр знімається зі стека.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(5);
Стек тут росте так: printSquare → square → multiply, а потім згортається у зворотному порядку. Коли ти бачиш stack trace у помилці — це знімок стека викликів у момент збою. Читається він згори вниз: угорі — функція, де саме сталася помилка (наймолодший кадр), а кожен рядок нижче — той, хто її викликав.
Ключова властивість: поки стек не порожній, потік зайнятий. Ніщо інше не виконається, доки поточний ланцюжок викликів не завершиться. Якщо якась функція крутиться в довгому циклі — стек «застряг» на ній, і браузер не робить нічого іншого. Це прямий шлях до блокування, до якого ще повернемось.
Переповнення стека (RangeError: Maximum call stack size exceeded) — це коли рекурсія без виходу кладе кадри швидше, ніж вони знімаються, і стек упирається у свій ліміт.
Event loop — цикл подій
Якщо потік один, а стек виконує все синхронно — як тоді працює асинхронність? Відповідь: цикл подій (event loop).
Event loop — це нескінченний цикл-координатор, який стежить за станом стека і черг. Його логіка спрощено така:
- Виконуй увесь синхронний код (стек росте і згортається).
- Коли стек порожній — візьми наступне завдання з черги і поклади його на стек.
- Повторюй.
Асинхронні операції не виконуються «в паралель» у твоєму коді. Вони працюють так: коли ти викликаєш setTimeout, fetch чи вішаєш обробник події (event handler), рушій передає цю роботу відповідному механізму браузера (Web API) і одразу йде далі, не чекаючи. Коли механізм завершив роботу (таймер відлічив час, відповідь прийшла, користувач клікнув), функція-обробник (callback) потрапляє в чергу. Event loop дістане її звідти й покладе на стек лише тоді, коли стек стане порожнім.
Звідси головний висновок: асинхронний колбек ніколи не перерве синхронний код, який уже виконується. Він завжди чекає своєї черги.
Черга задач vs черга мікрозадач
Черга не одна — і це деталь, яку найчастіше плутають. Черг щонайменше дві:
- Черга задач / макрозадач (task / macrotask queue) — сюди потрапляють колбеки
setTimeout,setInterval, обробники подій DOM, події XHR (onload,onreadystatechange); у Node.js такожsetImmediateта I/O. - Черга мікрозадач (microtask queue) — сюди потрапляють реакції промісів (
.then,.catch,.finally),queueMicrotask, колбекиMutationObserver.
Правило, яке пояснює майже всю поведінку асинхронного JS:
Після кожної однієї макрозадачі event loop повністю спорожнює чергу мікрозадач — виконує всі мікрозадачі до останньої (включно з тими, що додалися під час обробки) — і лише потім бере наступну макрозадачу.
Мікрозадачі мають пріоритет. Якщо всередині мікрозадачі створити ще одну мікрозадачу, вона теж виконається в цьому ж «прогоні», перш ніж дійде черга до макрозадач і до перемальовування сторінки. Саме перемальовування (rendering) браузер зазвичай робить між макрозадачами — вже після того, як спорожнив чергу мікрозадач, і лише коли є нагода для перемальовування (браузер прив’язує його до частоти оновлення екрана, тож рендер відбувається не після кожної задачі).
Класичний приклад, який варто вміти пояснити:
console.log('1: синхронний старт');
setTimeout(() => console.log('2: setTimeout (макрозадача)'), 0);
Promise.resolve().then(() => console.log('3: promise (мікрозадача)'));
console.log('4: синхронний кінець');
Порядок виводу:
1: синхронний старт
4: синхронний кінець
3: promise (мікрозадача)
2: setTimeout (макрозадача)
Розбір: спершу виконується весь синхронний код (1 і 4). Стек порожніє. Далі event loop спорожнює чергу мікрозадач — тому 3 (проміс) випереджає 2 (setTimeout), хоч setTimeout у коді записаний вище. І тільки потім береться макрозадача — 2.
| Аспект | Мікрозадачі (microtasks) | Макрозадачі (macrotasks) |
|---|---|---|
| Хто сюди потрапляє | .then/.catch/.finally, queueMicrotask, MutationObserver | setTimeout, setInterval, події DOM, події XHR |
| Коли виконуються | Уся черга — одразу після поточної задачі, до наступної макрозадачі | По одній за прохід циклу |
| Пріоритет | Вищий | Нижчий |
| Ризик | Нескінченне додавання мікрозадач може «заморозити» рендер | — |
Окремо про fetch, бо це часте джерело плутанини. Саме завершення мережі браузер оформлює як задачу (macrotask), яка резолвить проміс. Але реакції .then/.catch, повішані на цей проміс, виконуються вже як мікрозадачі. Тому обробник відповіді від fetch за пріоритетом поводиться як мікрозадача, а не як макрозадача — на відміну від старого XHR.onload, який є звичайною задачею.
Чому setTimeout(fn, 0) не миттєвий
setTimeout(fn, 0) не виконує fn негайно з двох незалежних причин.
Перша. fn — це макрозадача. Навіть із нульовою затримкою вона потрапляє в чергу і чекає, поки (а) завершиться весь поточний синхронний код і (б) спорожниться черга мікрозадач. Тобто «0 мс» — це не «зараз», а «якнайшвидше після того, як потік звільниться». Якщо синхронний код виконується 500 мс, колбек чекатиме ці 500 мс.
Друга. Значення затримки — це мінімум, а не гарантія. Браузер не зобовʼязаний виконати колбек рівно через задану кількість мілісекунд; він виконає його не раніше, ніж настане час і звільниться потік. Крім того, HTML Living Standard передбачає обмеження мінімальної затримки для вкладених таймерів: коли рівень вкладеності перевищує 5, а задана затримка менша за 4 мс, вона примусово піднімається до 4 мс. Тобто ланцюжок вкладених setTimeout(fn, 0) після кількох рівнів фактично тактує щонайменше кожні 4 мс.
Практичний висновок для AQA: setTimeout(fn, 0) — це спосіб «відкласти» роботу на наступний цикл, а не спосіб виміряти час. Будувати очікування в тестах на нульових чи фіксованих таймерах ненадійно, бо реальний час виконання залежить від завантаженості потоку.
Синхронний vs асинхронний код
Синхронний (synchronous) код виконується послідовно, рядок за рядком; кожен наступний рядок чекає завершення попереднього. Асинхронний (asynchronous) код запускає операцію і йде далі, не чекаючи результату; результат приходить пізніше — через колбек, проміс чи await.
// Синхронно (Node.js): наступний рядок чекає завершення читання
const data = readFileSync('config.json'); // блокує потік, поки файл не прочитано
console.log(data);
// Асинхронно: запит іде «у фон», потік вільний
fetch('/api/config').then(res => console.log(res)); // не блокує
console.log('цей рядок виконається ПЕРШИМ');
У другому випадку fetch делегує роботу мережевому механізму браузера, потік одразу йде до console.log('...ПЕРШИМ'), і лише коли відповідь прийде, реакція стане в чергу.
Асинхронність існує саме тому, що потік один. Якби кожен мережевий запит блокував потік, сторінка «застигала» б на кожному завантаженні даних. Асинхронна модель дозволяє одному потоку лишатися чутливим до дій користувача: він швидко роздає роботу назовні й обробляє результати в міру готовності.
Callback і callback hell
Найстаріший спосіб працювати з асинхронністю — колбек (callback): функція, яку передають як аргумент і викликають, коли операція завершилась.
getUser(1, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
console.log(details);
});
});
});
Коли асинхронні кроки залежать один від одного, колбеки вкладаються один в один. Це зростає вправо «драбинкою», яку називають callback hell (або «піраміда приреченості», pyramid of doom). Проблеми не лише косметичні:
- важко читати послідовність кроків;
- обробку помилок доводиться дублювати на кожному рівні;
- майже неможливо гарно виразити «зроби ці три речі паралельно, а потім одну — по завершенні всіх».
Саме щоб розплутати цю драбину, зʼявилися проміси.
Promise і його стани
Проміс (Promise) — це обʼєкт, що представляє результат асинхронної операції, який ще, можливо, не готовий. Це «обіцянка» дати значення пізніше: або успішне, або помилку.
Проміс має рівно три стани:
| Стан | Опис |
|---|---|
pending (очікування) | Операція ще триває, результату немає |
fulfilled (виконано) | Операція успішна, є значення |
rejected (відхилено) | Операція провалилась, є причина (помилка) |
Ключові властивості, які треба розуміти:
- Проміс починає життя в стані
pending. - Він переходить у
fulfilledабоrejectedлише один раз. Після цього стан і значення незмінні — проміс завершився (settled). Другий викликresolve/rejectігнорується. - Реакції, передані в
.then/.catch/.finally, виконуються як мікрозадачі.
function getUser(id) {
return new Promise((resolve, reject) => {
if (id <= 0) {
reject(new Error('Некоректний id'));
return;
}
// імітація асинхронної відповіді
setTimeout(() => resolve({ id, name: 'Ada' }), 100);
});
}
getUser(1)
.then(user => console.log('Отримали:', user.name))
.catch(err => console.error('Помилка:', err.message))
.finally(() => console.log('Запит завершено'));
Проміси розплутують callback hell, бо ланцюжок .then — це плаский список кроків, а не вкладеність, і єдиний .catch наприкінці ловить помилку з будь-якого кроку. Додатково є комбінатори: Promise.all (чекати всі, впасти на першій помилці), Promise.allSettled (дочекатися всіх незалежно від результату), Promise.race (перший, що завершився — байдуже, успіхом чи помилкою), Promise.any (перший успішний; відхилення ігноруються, доки не впадуть усі).
async/await
async/await — це синтаксичний цукор над промісами, який дозволяє писати асинхронний код так, ніби він синхронний.
- Функція, позначена
async, завжди повертає проміс. awaitперед промісом «ставить на паузу» виконання цієї функції, доки проміс не завершиться, і повертає його значення (або кидає помилку, якщоrejected).
async function loadOrders() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return details;
} catch (err) {
console.error('Щось пішло не так:', err.message);
}
}
Порівняй цей плаский, читабельний варіант із callback hell вище — це той самий ланцюжок, але виражений лінійно, з нормальним try/catch для помилок.
Важлива деталь про механіку: await не блокує потік. Коли виконання доходить до await, async-функція «відкладається», а потік звільняється і виконує іншу роботу. Продовження функції після await ставиться в чергу мікрозадач і виконається, коли проміс завершиться. Тобто await — це пауза для однієї функції, а не заморозка всього браузера.
Блокування головного потоку і наслідки
Оскільки потік один, будь-який довгий синхронний код блокує все: обробку кліків, анімації, перемальовування сторінки, виконання відкладених колбеків.
// Заморозить вкладку на кілька секунд
const end = Date.now() + 3000;
while (Date.now() < end) {
// порожній цикл — потік зайнятий, стек не порожніє
}
Поки цей цикл крутиться, стек не порожній, тож event loop не може взяти жодної задачі з черги. Наслідки для користувача: сторінка «зависла», кнопки не реагують, анімації стоять, вкладка може показати «сторінка не відповідає».
Типові причини блокування в реальному коді: важкі обчислення в циклі, синхронний парсинг великого JSON, синхронні операції над DOM у циклі. Рішення — розбивати роботу на частини (наприклад, через setTimeout/requestAnimationFrame, щоб віддати потік між шматками) або виносити обчислення у Web Worker (окремий потік без доступу до DOM).
Для тестувальника це має пряме значення. Якщо застосунок блокує потік, автотест може зафіксувати «неклікабельний» елемент, таймаут очікування або нестабільну поведінку — не тому, що елемента немає, а тому, що потік був зайнятий і не встиг обробити подію чи домалювати.
Навіщо тестам очікування
Автотест і застосунок працюють асинхронно й незалежно. Тест виконує команди швидко й послідовно, а браузер робить свою роботу (запити, рендер, реакції на події) у власному темпі через event loop. Між «тест наказав» і «браузер зробив» завжди є проміжок часу.
Очікування (wait) — це синхронізація тесту з реальним станом сторінки. Тест має дочекатися, поки настане потрібна умова, і лише тоді діяти чи перевіряти.
Наївний тест ігнорує цей проміжок:
// Крихко: клік може статись до того, як кнопка зʼявилась/увімкнулась
await page.click('#submit');
expect(await page.textContent('#status')).toBe('Готово');
Тут дві потенційні гонки: кнопки ще може не бути в DOM (її додає асинхронний код), а статус #status оновлюється після мережевого запиту, який ще триває.
Сучасні інструменти (наприклад, Playwright) вбудовують автоочікування (auto-waiting): перед дією локатор чекає, поки елемент зʼявиться, стане видимим, стабільним і придатним для взаємодії. Це працює саме тому, що інструмент моделює асинхронну природу сторінки замість того, щоб діяти наосліп. Але автоочікування не всесильне — умови на кшталт «дані в таблиці оновилися» треба виражати явними очікуваннями стану, а не самого елемента.
Race condition на конкретному прикладі
Гонка (race condition) — це ситуація, коли результат залежить від того, у якому порядку завершилися асинхронні операції, а цей порядок не гарантований.
Класичний приклад — живий пошук (autocomplete). Користувач швидко набирає ab, потім abc. Летять два запити. Логічно очікувати, що на екрані будуть результати для abc. Але:
let latestResults = [];
input.addEventListener('input', async (e) => {
const query = e.target.value;
const results = await fetchSearch(query); // час відповіді непередбачуваний
latestResults = results;
render(results);
});
Якщо відповідь на ab прийде пізніше, ніж на abc (мережа непередбачувана, запити можуть завершитись у будь-якому порядку), то render(results) для ab виконається останнім і перезапише правильні результати для abc. Користувач набрав abc, а бачить результати для ab. Це і є гонка: «останній, що відповів, перемагає», хоча має перемагати «останній, що надіслали».
Виправлення — відстежувати актуальність запиту (наприклад, скасовувати попередній через AbortController або ігнорувати відповіді, чий запит уже застарів):
let requestId = 0;
input.addEventListener('input', async (e) => {
const current = ++requestId;
const results = await fetchSearch(e.target.value);
if (current !== requestId) return; // прийшла відповідь на застарілий запит — ігноруємо
render(results);
});
Гонки — головна причина флаку (flakiness) в автотестах. Тест, який не синхронізується зі станом, фактично сам створює гонку: іноді браузер устигає, іноді ні. Звідси «то зелений, то червоний» без змін у коді. Розуміння event loop дозволяє бачити ці гонки й закривати їх правильними очікуваннями, а не випадковістю.
Чому sleep(3000) — поганий спосіб чекати
Спокуса «полагодити» флак додаванням sleep(3000) (жорсткої паузи на фіксований час) велика, але це антипатерн. Причини:
- Час непередбачуваний. Реальна затримка залежить від мережі, завантаженості CI, потужності машини.
3000мс, що працює локально, може не вистачити на повільному раннері — тест знову флакне. А те, що вистачає, майже завжди більше, ніж треба. - Тести стають повільними. Кожен фіксований
sleepдодається до загального часу. Сотні тестів × секунди «про запас» — це десятки зайвих хвилин на прогін. Ти платиш максимальний час навіть тоді, коли сторінка готова за 50 мс. - Не усуває гонку, а маскує її.
sleepне перевіряє стан — він просто сподівається, що за цей час усе встигне. Умова, від якої залежить тест, лишається невираженою. Це прихована крихкість, яка вистрелить у найгірший момент.
Правильний підхід — очікування за умовою (conditional wait): чекати рівно доти, доки настане потрібний стан, і не довше.
// Погано: фіксована пауза наосліп
await page.waitForTimeout(3000);
await expect(page.locator('#status')).toHaveText('Готово');
// Добре: чекаємо саме потрібний стан, з розумним таймаутом
await expect(page.locator('#status')).toHaveText('Готово', { timeout: 10000 });
Різниця принципова. Очікування за умовою завершується щойно умова виконана (швидко, коли все гаразд), і падає з осмисленою помилкою, якщо умова так і не настала за розумний максимальний таймаут. Таймаут тут — це стеля терпіння, а не фіксований простій. Так тести лишаються і швидкими, і стабільними.
Рідкісний виняток, де коротка фіксована пауза може бути виправдана, — коли немає жодного спостережуваного стану, за яким можна чекати (наприклад, дебаунс-анімація без будь-якого сигналу завершення). Але навіть тоді краще знайти сигнал: перехопити мережевий запит (page.route/waitForResponse), дочекатися зникнення спінера чи появи класу-маркера. Майже завжди спостережуваний стан існує — його треба лише знайти й виразити.
Практичний підсумок для AQA
Модель виконання JS — не абстрактна теорія, а щоденний інструмент діагностики:
- «Клік не спрацював» — можливо, потік був заблокований довгим синхронним кодом, і подія не оброблялась.
- «Елемент є в DOM, але тест падає» — можливо, його вміст оновлює мікрозадача чи мережевий колбек, який ще не відпрацював.
- «Тест флакне без змін у коді» — майже напевно гонка між темпом тесту і темпом асинхронного застосунку.
- «Порядок логів не такий, як я думав» — згадай про пріоритет мікрозадач над макрозадачами.
Хто розуміє call stack, черги й event loop, той не ліпить sleep навмання, а бачить конкретну асинхронну операцію, якої треба дочекатися, і виражає це очікування точно.
Що означає, що JavaScript однопотоковий, і чому мову зробили саме такою?
Однопотоковість означає, що в кожен момент часу рушій виконує рівно один фрагмент коду — паралельного виконання твого JS немає взагалі. Причина в призначенні мови: вона створювалась для маніпуляцій із DOM, а якби два потоки могли одночасно змінювати одне дерево елементів, знадобилися б блокування (locks), і будь-яка помилка розробника вела б до дедлоків і гонок прямо у відмальовуванні сторінки. Один потік прибирає цей клас проблем: порядок операцій над DOM у межах одного синхронного блоку завжди детермінований. Важливо не сплутати два поняття: однопотоковий — саме рушій виконання JS-коду, а браузер (чи Node.js) загалом багатопотоковий — мережеві запити, таймери, декодування зображень виконують окремі системні механізми поза потоком JS. Тобто «важку» роботу браузер робить назовні й повертає результат, коли потік JS звільниться, — саме на цьому стику народжується асинхронність. Для тестувальника це базова модель: усе «відкладене» проходить через черги до одного потоку, і саме звідси ростуть очікування в автотестах.
Що таке call stack і за яким принципом він працює?
Стек викликів (call stack) — це структура, в якій рушій відстежує, яка функція зараз виконується і хто її викликав; працює за принципом LIFO (last in, first out). Коли функція викликається, на вершину стека кладеться її кадр (frame) — запис про виклик; коли функція завершується, кадр знімається. Ключова властивість: поки стек не порожній, потік зайнятий — ніщо інше не виконається, доки поточний ланцюжок викликів не завершиться, тому довгий цикл у якійсь функції «підвішує» весь браузер. Stack trace у помилці — це знімок стека в момент збою: читається згори вниз, угорі — функція, де сталася помилка (наймолодший кадр), кожен рядок нижче — той, хто її викликав. Окремий випадок — RangeError: Maximum call stack size exceeded: рекурсія без виходу кладе кадри швидше, ніж вони знімаються, і стек упирається у свій ліміт. Для AQA stack trace — перший інструмент локалізації падіння: він показує точний ланцюжок викликів до місця збою.
Чим синхронний код відрізняється від асинхронного?
Синхронний (synchronous) код виконується послідовно, рядок за рядком: кожен наступний рядок чекає завершення попереднього. Асинхронний (asynchronous) — запускає операцію і йде далі, не чекаючи результату; результат приходить пізніше через колбек, проміс чи await. Контрастний приклад: readFileSync('config.json') блокує потік, доки файл не прочитано, а після fetch('/api/config').then(...) наступний рядок виконається одразу, першим — запит пішов «у фон». Асинхронність існує саме тому, що потік один: якби кожен мережевий запит блокував потік, сторінка «застигала» б на кожному завантаженні даних, а так потік роздає роботу назовні й обробляє результати в міру готовності. Для тестів наслідок прямий: між «тест наказав» і «браузер зробив» завжди є проміжок часу, і закривати його треба очікуваннями, а не сподіванням.
Що таке event loop і навіщо він потрібен?
Event loop (цикл подій) — це нескінченний цикл-координатор, який стежить за станом стека і черг; саме він відповідає на питання «як працює асинхронність, якщо потік один». Логіка спрощено така: виконуй увесь синхронний код, поки стек росте і згортається; коли стек порожній — візьми наступне завдання з черги і поклади його на стек; повторюй. Асинхронні операції не виконуються «в паралель» із твоїм кодом: коли викликаєш setTimeout, fetch чи вішаєш обробник події, рушій передає роботу відповідному механізму браузера (Web API) і одразу йде далі, а коли механізм завершив роботу — таймер відлічив, відповідь прийшла, користувач клікнув — колбек потрапляє в чергу. Звідси головний висновок: асинхронний колбек ніколи не перерве синхронний код, який уже виконується, — він завжди чекає, поки стек спорожніє. Тому «виконати через 0 мс» насправді означає «не раніше, ніж потік звільниться».
Що таке callback hell і чим він поганий?
Колбек (callback) — найстаріший спосіб працювати з асинхронністю: функція, яку передають як аргумент і викликають, коли операція завершилась. Проблема починається, коли асинхронні кроки залежать один від одного: колбеки вкладаються один в один і код росте вправо «драбинкою» — це і називають callback hell або «пірамідою приреченості» (pyramid of doom). Проблеми не лише косметичні: важко читати послідовність кроків, обробку помилок доводиться дублювати на кожному рівні, і майже неможливо гарно виразити «зроби ці три речі паралельно, а потім одну — по завершенні всіх». Саме щоб розплутати цю драбину, з'явилися проміси: ланцюжок .then — плаский список кроків, а єдиний .catch наприкінці ловить помилку з будь-якого кроку.
Які стани має Promise і що означає «проміс завершився» (settled)?
Проміс (Promise) — це об'єкт, що представляє результат асинхронної операції, який ще, можливо, не готовий; станів у нього рівно три. pending — операція триває, результату немає; fulfilled — операція успішна, є значення; rejected — операція провалилась, є причина-помилка. Проміс починає життя в pending і переходить у fulfilled або rejected лише один раз — після цього він settled, стан і значення незмінні, а другий виклик resolve чи reject просто ігнорується. Реакції, передані в .then/.catch/.finally, виконуються як мікрозадачі — тобто з вищим пріоритетом, ніж setTimeout. На співбесіді часто чекають саме на деталь про незмінність: проміс не можна «перезапустити» чи перевести назад у pending — для повторної операції створюють новий проміс.
Що робить async/await і чи блокує await потік?
async/await — це синтаксичний цукор над промісами, який дозволяє писати асинхронний код так, ніби він синхронний: плаский, лінійний, зі звичайним try/catch для помилок замість дублювання обробки на кожному рівні колбеків. Дві механічні властивості: функція з async завжди повертає проміс, а await перед промісом «ставить на паузу» виконання саме цієї функції, доки проміс не завершиться, і повертає значення або кидає помилку, якщо проміс rejected. Принципово: await не блокує потік — коли виконання доходить до await, функція «відкладається», потік звільняється і робить іншу роботу, а продовження функції після await ставиться в чергу мікрозадач і виконається, коли проміс завершиться. Тобто await — пауза для однієї функції, а не заморозка всього браузера. Для AQA це рідна механіка: тестові сценарії на Playwright — це ті самі ланцюжки await, виражені лінійно, і розуміння, що під кожним із них проміс, пояснює і порядок виконання, і поведінку при помилках.
Чим мікрозадачі відрізняються від макрозадач?
Черга задач у браузері не одна — і це деталь, яку найчастіше плутають. У чергу макрозадач (task/macrotask queue) потрапляють колбеки setTimeout і setInterval, обробники подій DOM, події XHR; у чергу мікрозадач (microtask queue) — реакції промісів .then/.catch/.finally, queueMicrotask, колбеки MutationObserver. Правило, яке пояснює майже всю поведінку асинхронного JS: після кожної однієї макрозадачі event loop повністю спорожнює чергу мікрозадач — виконує всі до останньої, включно з тими, що додалися під час обробки, — і лише потім бере наступну макрозадачу. Тобто мікрозадачі мають вищий пріоритет, а макрозадачі виконуються по одній за прохід циклу. Перемальовування сторінки браузер зазвичай робить між макрозадачами, вже після спорожнення мікрозадач, і прив'язує його до частоти оновлення екрана. Звідси й ризик: нескінченне додавання мікрозадач (кожна створює наступну) здатне «заморозити» рендер, бо до макрозадач і перемальовування черга просто не дійде.
Що виведе код із console.log, setTimeout(fn, 0) і Promise.resolve().then і чому саме в такому порядку?
Класика співбесід: чотири рядки поспіль — console.log('1'), потім setTimeout(() => console.log('2'), 0), потім Promise.resolve().then(() => console.log('3')), потім console.log('4'). Вивід буде 1, 4, 3, 2. Механізм: спершу виконується весь синхронний код, тому 1 і 4 йдуть першими, а обидва колбеки лише стають у черги — setTimeout у макрозадачі, реакція проміса в мікрозадачі. Коли стек порожніє, event loop спочатку повністю спорожнює чергу мікрозадач — тому 3 випереджає 2, хоч setTimeout у коді записаний вище. І лише потім береться макрозадача — 2. Якщо кандидат може не просто назвати порядок, а пояснити його через стек і дві черги, — питання закрите; на практиці ця сама логіка пояснює «дивний» порядок логів під час дебагу тестів.
Чому setTimeout(fn, 0) не виконується миттєво?
З двох незалежних причин. Перша: колбек setTimeout — це макрозадача, тож навіть із нульовою затримкою він потрапляє в чергу і чекає, поки завершиться весь поточний синхронний код і спорожніє черга мікрозадач; «0 мс» — це не «зараз», а «якнайшвидше після того, як потік звільниться», і якщо синхронний код крутиться 500 мс, колбек чекатиме ці 500 мс. Друга: значення затримки — це мінімум, а не гарантія; браузер виконає колбек не раніше заданого часу і не раніше, ніж звільниться потік, але може й пізніше. Плюс деталь зі стандарту, яка вражає на співбесіді: HTML Living Standard обмежує мінімальну затримку для вкладених таймерів — коли рівень вкладеності перевищує 5, а задана затримка менша за 4 мс, вона примусово піднімається до 4 мс, тож ланцюжок вкладених setTimeout(fn, 0) фактично тактує щонайменше кожні 4 мс. Практичний висновок для AQA: setTimeout(fn, 0) — спосіб «відкласти» роботу на наступний цикл, а не інструмент вимірювання часу, і будувати очікування в тестах на нульових чи фіксованих таймерах ненадійно.
Чим відрізняються Promise.all, Promise.allSettled, Promise.race і Promise.any?
Це чотири комбінатори з різною політикою щодо помилок і моменту завершення. Promise.all чекає всі проміси й падає на першій же помилці — підходить, коли потрібні всі результати і будь-який провал робить решту безглуздою. Promise.allSettled дочікується всіх незалежно від результату й віддає повну картину успіхів і провалів — зручно, коли треба виконати все і потім розібратися, що вижило. Promise.race повертає перший проміс, що завершився, — байдуже, успіхом чи помилкою. Promise.any повертає перший успішний, а відхилення ігнорує, доки не впадуть усі. У тестовій обв'язці це щоденні інструменти: наприклад, паралельна підготовка кількох сутностей через Promise.all пришвидшує setup, але треба усвідомлювати, що перша ж помилка обірве все очікування.
fetch — це макрозадача чи мікрозадача?
Питання-пастка, бо правильна відповідь — «і те, і те, залежно від шару». Саме завершення мережевої операції браузер оформлює як задачу (macrotask), яка резолвить проміс. Але реакції .then/.catch, повішані на цей проміс, виконуються вже як мікрозадачі — тож обробник відповіді від fetch за пріоритетом поводиться як мікрозадача. Це відрізняє його від старого XHR.onload, який є звичайною задачею. Сильний кандидат тут показує, що розуміє межу між «подія завершилась» і «мій код на неї відреагував» — це різні кроки циклу подій із різними чергами.
Що станеться, якщо синхронний код надовго заблокує головний потік, і як це проявляється в автотестах?
Оскільки потік один, довгий синхронний код блокує все: обробку кліків, анімації, перемальовування сторінки, виконання відкладених колбеків. Достатньо порожнього циклу на кшталт while (Date.now() < end) — поки він крутиться, стек не порожній, і event loop не може взяти жодної задачі з черги; для користувача сторінка «зависла», кнопки не реагують, вкладка може показати «сторінка не відповідає». Типові реальні причини: важкі обчислення в циклі, синхронний парсинг великого JSON, синхронні операції над DOM у циклі. Рішення — розбивати роботу на частини через setTimeout/requestAnimationFrame, щоб віддавати потік між шматками, або виносити обчислення у Web Worker — окремий потік без доступу до DOM. Для тестувальника це важливий клас діагнозів: автотест фіксує «неклікабельний» елемент, таймаут очікування чи нестабільну поведінку не тому, що елемента немає, а тому, що потік був зайнятий і не встиг обробити подію чи домалювати. Тобто падіння тесту тут — симптом проблеми продуктивності застосунку, і репортити варто саме її.
Навіщо автотестам очікування і як працює автоочікування в Playwright?
Тест і застосунок працюють асинхронно й незалежно: тест виконує команди швидко й послідовно, а браузер робить свою роботу — запити, рендер, реакції на події — у власному темпі через event loop, тож між «тест наказав» і «браузер зробив» завжди є проміжок часу. Очікування (wait) — це синхронізація тесту з реальним станом сторінки: дочекатися потрібної умови й лише тоді діяти чи перевіряти. Наївний тест, який одразу клікає і одразу читає текст, закладає одразу дві гонки: кнопки ще може не бути в DOM, а статус оновлюється після мережевого запиту, який ще триває. Playwright вбудовує автоочікування (auto-waiting): перед дією локатор чекає, поки елемент з'явиться, стане видимим, стабільним і придатним для взаємодії — інструмент моделює асинхронну природу сторінки замість того, щоб діяти наосліп. Але автоочікування не всесильне: умови на кшталт «дані в таблиці оновилися» стосуються стану, а не самого елемента, і їх треба виражати явними очікуваннями. Розуміння event loop якраз і підказує, якої саме асинхронної операції треба дочекатися.
Що таке race condition? Наведіть приклад із фронтенду і спосіб виправлення
Гонка (race condition) — це ситуація, коли результат залежить від порядку завершення асинхронних операцій, а цей порядок не гарантований. Класика — живий пошук: користувач швидко набирає ab, потім abc, летять два запити, і якщо відповідь на ab прийде пізніше, ніж на abc, то рендер результатів для ab виконається останнім і перезапише правильні результати — користувач набрав abc, а бачить видачу для ab. Суть бага: «останній, що відповів, перемагає», хоча має перемагати «останній, що надіслали». Виправлення — відстежувати актуальність запиту: скасовувати попередній через AbortController або нумерувати запити лічильником і ігнорувати відповіді, чий номер уже застарів. Для AQA гонки — головна причина флаку: тест, який не синхронізується зі станом сторінки, сам створює гонку між своїм темпом і темпом застосунку — іноді браузер устигає, іноді ні, звідси «то зелений, то червоний» без змін у коді. Розуміння event loop дозволяє бачити такі гонки й закривати їх правильними очікуваннями, а не перезапусками.
Чому sleep(3000) — поганий спосіб стабілізувати тест і що використовувати замість нього?
Жорстка пауза на фіксований час — антипатерн із трьох причин. Перша: час непередбачуваний — реальна затримка залежить від мережі, завантаженості CI, потужності машини, тож 3000 мс, яких вистачає локально, може забракнути на повільному раннері, і тест знову флакне. Друга: тести стають повільними — ти платиш максимальний час навіть тоді, коли сторінка готова за 50 мс, і сотні тестів із секундами «про запас» перетворюються на десятки зайвих хвилин прогону. Третя, найважливіша: sleep не усуває гонку, а маскує її — він не перевіряє стан, а просто сподівається, що все встигне, тож умова, від якої залежить тест, лишається невираженою. Правильний підхід — очікування за умовою (conditional wait): наприклад, await expect(locator).toHaveText('Готово', { timeout: 10000 }) завершується щойно умова виконана і падає з осмисленою помилкою, якщо стан так і не настав; таймаут тут — стеля терпіння, а не фіксований простій. Рідкісний виняток — коли спостережуваного стану справді немає, але майже завжди він є, його треба лише знайти: перехопити мережевий запит через waitForResponse, дочекатися зникнення спінера чи появи класу-маркера.
Три кейси, де модель виконання JS вирішує, зелений тест чи флак: передбачення порядку логів через пріоритет мікрозадач над макрозадачами, діагностика гонки в живому пошуку (autocomplete) через page.route, і заміна крихкого sleep на очікування за умовою в Playwright. Скрізь — що дивитися і чому.
Кейс 1. Порядок логів: чому Promise випереджає setTimeout
Класичне питання зі співбесіди: передбачити вивід. Інтуїція читає код згори вниз і ставить setTimeout перед промісом, бо він записаний вище. Насправді порядок інший.
console.log('1: синхронний старт');
setTimeout(() => console.log('2: setTimeout (макрозадача)'), 0);
Promise.resolve().then(() => console.log('3: promise (мікрозадача)'));
console.log('4: синхронний кінець');
Фактичний вивід:
1: синхронний старт
4: синхронний кінець
3: promise (мікрозадача)
2: setTimeout (макрозадача)
Що дивитися і чому:
- Спершу — увесь синхронний код.
1і4виводяться першими, бо це синхронні виклики; поки стек не порожній, жоден колбек із черги не виконається. - Мікрозадача випереджає макрозадачу. Коли стек порожніє, event loop повністю спорожнює чергу мікрозадач до наступної макрозадачі. Реакція
.then— мікрозадача, тож3іде раніше за2, хочаsetTimeoutзаписаний вище. setTimeout(fn, 0)не миттєвий.0— не «зараз», а «якнайшвидше після того, як звільниться потік і спорожніє черга мікрозадач». Якщо перед ним крутиться довгий синхронний код, колбек чекає весь цей час.- Практичний висновок для AQA. Коли «порядок логів не такий, як я думав» — згадай про пріоритет мікрозадач. Обробник
fetch-відповіді (.then) поводиться як мікрозадача, а старийXHR.onload— як звичайна задача.
Кейс 2. Гонка в живому пошуку: «останній, що відповів, перемагає»
Живий пошук шле запит на кожен ввід. Користувач набирає ab, потім abc — летять два запити. На екрані очікуємо результати для abc, але мережа непередбачувана, і відповіді можуть завершитись у будь-якому порядку.
let latestResults = [];
input.addEventListener('input', async (e) => {
const query = e.target.value;
const results = await fetchSearch(query); // час відповіді непередбачуваний
latestResults = results;
render(results);
});
Якщо відповідь на ab прийде пізніше, ніж на abc, вона виконає render останньою і перезапише правильні результати. Це гонка: перемагає «останній, що відповів», хоча має перемагати «останній, що надіслали».
В автотесті цю гонку відтворюють детерміновано — не грою з реальними затримками, а керуванням порядком відповідей через перехоплення мережі:
import { test, expect } from '@playwright/test';
test('пошук показує результати для останнього введеного запиту', async ({ page }) => {
// штучно затримуємо відповідь на "ab", щоб вона прийшла ПІСЛЯ "abc"
await page.route('**/api/search**', async (route, request) => {
const q = new URL(request.url()).searchParams.get('q');
if (q === 'ab') await new Promise((r) => setTimeout(r, 500));
await route.fulfill({ json: { items: [`результат для ${q}`] } });
});
await page.fill('#search', 'ab');
await page.fill('#search', 'abc');
// очікуємо саме потрібний стан, не фіксовану паузу
await expect(page.locator('#results')).toHaveText('результат для abc');
});
Що дивитися і чому:
page.routeробить гонку відтворюваною. Затримавши відповідь наab, ми гарантовано ставимо її післяabc— тест падає стабільно, поки баг не виправлено, а не «то зелений, то червоний».- Гонка — головна причина флаку. Тест, який не синхронізується зі станом, сам створює гонку: іноді браузер устигає, іноді ні. Виправлення в застосунку — відстежувати актуальність запиту (
AbortControllerабо лічильникrequestId, що ігнорує застарілі відповіді). - Перевіряй стан, а не факт появи елемента. Контейнер
#resultsможе бути в DOM увесь час; діагностична умова — саме його текст, який оновлює мережевий колбек.
Кейс 3. sleep(3000) vs очікування за умовою
Спокуса «полагодити» флак жорсткою паузою велика, але це антипатерн: sleep не перевіряє стан, а лише сподівається, що за цей час усе встигне. Час непередбачуваний (мережа, завантаженість CI, потужність раннера), тести стають повільними, а гонка лишається замаскованою.
// Погано: фіксована пауза наосліп — платимо максимум навіть коли готово за 50 мс
await page.waitForTimeout(3000);
await expect(page.locator('#status')).toHaveText('Готово');
// Добре: чекаємо саме потрібний стан, зі стелею таймауту
await expect(page.locator('#status')).toHaveText('Готово', { timeout: 10000 });
Різницю видно і в наївному тесті з двома прихованими гонками — кнопки ще може не бути в DOM, а статус оновлюється після мережевого запиту, який ще триває:
// Крихко: клік може статись до того, як кнопка зʼявилась/увімкнулась
await page.click('#submit');
expect(await page.textContent('#status')).toBe('Готово');
Що дивитися і чому:
- Таймаут — це стеля терпіння, а не простій. Очікування за умовою завершується щойно умова виконана (швидко, коли все гаразд), і падає з осмисленою помилкою, якщо стан так і не настав.
waitForTimeoutзавжди чекає всі 3000 мс. textContent— миттєвий знімок,toHaveText— очікування. Перший читає DOM «прямо зараз» і фіксує гонку; другий перепитує, доки текст не збіжиться або не спливе таймаут.- Автоочікування не всесильне. Локатор Playwright сам чекає видимості й придатності елемента, але умови на кшталт «дані в таблиці оновилися» треба виражати явними очікуваннями стану, а не самого елемента.
- Рідкісний виняток. Коротка фіксована пауза виправдана лише коли немає жодного спостережуваного сигналу (дебаунс-анімація без сигналу завершення). Але майже завжди сигнал існує — перехопи запит (
waitForResponse), дочекайся зникнення спінера чи класу-маркера.
Модель виконання: потік, стек, event loop
- Розумію, чому JavaScript однопотоковий (single-threaded): один потік прибирає локи, дедлоки й гонки над DOM у межах синхронного блоку.
- Знаю різницю між «рушій JS однопотоковий» і «браузер багатопотоковий»: мережу, таймери, I/O виконують окремі механізми поза потоком JS.
- Можу пояснити call stack (LIFO) і як читати stack trace: угорі — кадр, де стався збій, нижче — хто його викликав.
- Розумію, чому
RangeError: Maximum call stack size exceeded— це рекурсія без виходу, що кладе кадри швидше, ніж вони знімаються. - Можу пояснити event loop у трьох кроках: виконати синхронний код → на порожньому стеку взяти завдання з черги → повторити.
- Знаю ключовий інваріант: асинхронний колбек ніколи не перерве синхронний код, що вже виконується.
Черги, проміси, async/await
- Знаю різницю між чергою макрозадач (
setTimeout,setInterval, події DOM, XHR) і чергою мікрозадач (.then/.catch/.finally,queueMicrotask,MutationObserver). - Розумію правило пріоритету: після кожної однієї макрозадачі event loop повністю спорожнює чергу мікрозадач, і лише потім бере наступну макрозадачу.
- Можу передбачити порядок виводу для
console.log+setTimeout(…,0)+Promise.resolve().then(…): спершу весь синхронний код, потім мікрозадача, потім макрозадача. - Можу пояснити, чому
fetch— пастка: завершення мережі оформлюється як макрозадача, але реакції.thenна її промісі виконуються як мікрозадачі (на відміну відXHR.onload). - Знаю три стани промісу (
pending,fulfilled,rejected) і що перехід відбувається лише один раз, після чого стан незмінний (settled). - Можу пояснити комбінатори:
Promise.all,Promise.allSettled,Promise.race,Promise.anyі чим вони різняться. - Розумію, чому
async-функція завжди повертає проміс і чомуawaitне блокує потік, а лише відкладає продовження функції в чергу мікрозадач. - Можу пояснити callback hell («піраміда приреченості») і як проміси та
async/awaitрозплутують його в плаский ланцюжок.
Таймери, блокування, наслідки для тестів
- Розумію, чому
setTimeout(fn, 0)не миттєвий:fn— макрозадача, а затримка — мінімум, не гарантія (плюс кламп до 4 мс для вкладених таймерів глибше 5 рівнів). - Можу пояснити, чому довгий синхронний код блокує все: поки стек не порожній, event loop не бере жодної задачі — звідси «сторінка не відповідає»; рішення — розбити роботу або винести у Web Worker.
- Розумію, чому тестам потрібні очікування: тест і браузер працюють асинхронно й незалежно, між «наказав» і «зробив» завжди є проміжок.
- Можу пояснити race condition на прикладі живого пошуку («останній, що відповів, перемагає» замість «останній, що надіслали») і чому гонки — головна причина флаку.
- Розумію, чому
sleep(3000)— антипатерн: час непередбачуваний, тести повільні, гонка не усунена, а замаскована; правильно — очікування за умовою з таймаутом-стелею.
Що означає «JavaScript у браузері однопотоковий»?
Питання
JavaScript однопотоковий. Чому тоді сторінка не «застигає» на кожному завантаженні даних?