Przejdź do treści

Event Loop w JavaScript - jak silnik decyduje, co odpalić następne

Profile photo of Adrian Zawadzki

Adrian Zawadzki

16 min czytania

JavaScript ma jeden wątek, ale potrafi czekać na wiele rzeczy równocześnie. Sekret to event loop - prosty algorytm, który wybiera, co odpalić, gdy stack jest pusty. Microtaski wyprzedzają taski, render wpasowuje się między cyklami, a kolejność wykonania zaczyna mieć logiczny sens.

Cztery linijki, które przy pierwszym kontakcie wyglądają na łamigłówkę:

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

Intuicja podpowiada 1, 2, 3, 4. Albo 1, 3, 4, 2, bo setTimeout jest opóźniony i powinien polecieć na koniec. W rzeczywistości output to 1, 4, 3, 2.

Dwa pytania, na które trzeba sobie odpowiedzieć:

  1. Dlaczego 4 jest przed 2, skoro setTimeout(0) ma „zerowe” opóźnienie?
  2. Dlaczego 3 jest przed 2, choć setTimeout był pierwszy w kodzie?

Odpowiedź: event loop. Mechanizm, który decyduje, co JavaScript odpali następne, gdy stack jest pusty. Reszta posta rozkłada ten algorytm na części: jeden wątek, dwie kolejki callbacków, render step. Pod koniec output 1, 4, 3, 2 przestanie być zagadką.

#JavaScript jest single-threaded - dlatego potrzebuje event loop

W silniku JS jest jeden stack i jeden wątek wykonawczy. Jedna linia kodu na raz, do końca, zanim silnik weźmie następną. Wszystko, co w kodzie wygląda na równoczesne, w rzeczywistości dzieje się jedno po drugim. Model omówiłem w poście o execution context - tu interesuje nas, co z niego wynika.

Operacje, które trwają w czasie (timer, network request, oczekiwanie na klik), nie mogą odbywać się „w” JavaScripcie. Gdyby próbowały, pojedynczy wątek by je zablokował. setTimeout(callback, 1000) zatrzymałby cały skrypt na sekundę. Pierwszy fetch zamroziłby UI na 100ms.

Dlatego te operacje siedzą poza JavaScriptem, w środowisku hosta (przeglądarka albo Node.js). Host ma własne wątki do timerów, sieci i eventów użytkownika - tam te operacje rzeczywiście się dzieją. JavaScript zleca operację („obudź mnie za sekundę”) i wraca do swojej roboty. Host odzywa się z powrotem dopiero, gdy wynik jest gotowy.

Tym mechanizmem - łączeniem JS-owego wątku z gotowymi callbackami z hosta - zajmuje się event loop.

#Z czego się składa event loop

Mechanizm składa się z czterech elementów. Każdy ma swoją rolę:

Call stack - tu fizycznie wykonuje się kod. Funkcja wchodzi (push), kończy (pop). Pełny opis w poście o execution context.

Web APIs / host environment - funkcjonalności poza JavaScriptem, dostarczane przez przeglądarkę albo Node.js. W przeglądarce: timery, fetch, DOM events, IndexedDB. W Node.js: fs, http, setImmediate. Każda z nich w pewnym momencie ma callback do odpalenia.

Task queue (znana też jako callback queue albo macrotask queue) - kolejka, do której host wkłada callbacki, gdy są gotowe do uruchomienia. Tu ląduje callback setTimeout po opóźnieniu, tu ląduje DOM event handler po kliku.

Microtask queue - druga kolejka, z wyższym priorytetem niż task queue. Tu lądują callbacki od Promise.then / .catch / .finally, kontynuacje po await i wszystko zaplanowane przez queueMicrotask().

Event loop to algorytm, który koordynuje tę czwórkę: sprawdza kolejki, kładzie callbacki na stack, sprawdza znowu. Działa bez przerwy, dopóki strona żyje.

#Task queue - jeden task na cykl

Najprostszy przykład:

console.log("synchroniczne");
setTimeout(() => console.log("po sekundzie"), 1000);
console.log("dalej synchroniczne");

Co się dzieje, krok po kroku:

  1. Silnik wchodzi w console.log("synchroniczne"), wypisuje, wychodzi. Konsola: synchroniczne.
  2. Silnik wchodzi w setTimeout(callback, 1000). Sam setTimeout nie odpala tego callbacka - tylko zleca przeglądarce „uruchom mi ten callback za 1000ms”. Wraca natychmiast, bez czekania.
  3. Silnik wchodzi w console.log("dalej synchroniczne"), wypisuje. Konsola: dalej synchroniczne.
  4. Skrypt skończony. Call stack jest pusty.
  5. Po 1000ms przeglądarka robi swoje: bierze callback od timera i wkłada go do task queue.
  6. Event loop widzi pusty stack i niepustą task queue. Wyciąga callback, kładzie na stack, silnik go wykonuje. Konsola: po sekundzie.

Co ląduje w task queue:

  • setTimeout / setInterval callbacks
  • DOM events (click, keydown, scroll)
  • Network request callbacks (onload, onerror)
  • całe wykonanie skryptu (<script>) - sam start strony to też task

Najważniejsza zasada: w każdym cyklu event loop wyciąga z task queue dokładnie jeden callback. Wykonuje go do końca, potem zajmuje się microtaskami, potem ewentualnie renderem (czyli odświeżeniem obrazu na ekranie - o tym za chwilę osobno). Dopiero w kolejnym cyklu sięgnie po następny callback z task queue. Nigdy nie bierze dwóch tasków pod rząd.

#Microtask queue - cała kolejka na cykl

Promise.then zachowuje się inaczej:

console.log("synchroniczne");
Promise.resolve("ok").then((value) => console.log(value));
console.log("dalej synchroniczne");

Output: synchroniczne, dalej synchroniczne, ok.

Promise callback nie idzie do task queue. Idzie do microtask queue. Różnica wygląda subtelnie, ale ma kluczowy skutek:

w każdym cyklu event loop opróżnia całą microtask queue do dna. Konkretnie: bierze pierwszy callback z microtask queue i wykonuje. Bierze drugi - wykonuje. Trzeci - wykonuje. I tak dopóki kolejka nie będzie pusta.

Dla porównania: z task queue event loop wyciąga tylko jeden callback na cykl. Z microtask queue wyciąga wszystkie, jeden po drugim, aż się skończą.

Co ląduje w microtask queue:

  • Promise.then / .catch / .finally callbacki
  • kontynuacje po await (kod po await w funkcji async) - opisałem to w poście o async/await
  • queueMicrotask(fn) - jawne dodanie microtaska
  • MutationObserver callbacks (zmiany w DOM)

Wszystko z tej listy ma wyższy priorytet niż callbacki z task queue. Gdy stack jest pusty, event loop najpierw opróżnia całą microtask queue, a dopiero potem sięga po kolejny callback z task queue.

#Algorytm event loop - oficjalna kolejność

Wszystko składa się w algorytm zdefiniowany w HTML Standard § 8.1.7 Event loops. Pseudokod (uproszczony):

while (true) {
  // 1. Weź jeden task z task queue (jeśli jest)
  const task = taskQueue.shift();
  if (task) executeUntilStackEmpty(task);

  // 2. Opróżnij CAŁĄ microtask queue
  while (microtaskQueue.length > 0) {
    const microtask = microtaskQueue.shift();
    executeUntilStackEmpty(microtask);
  }

  // 3. Może render (przeglądarka uruchamia task z rendering task
  //    source w rytm odświeżania ekranu - tu pokazany jako osobny krok)
  if (shouldRender()) {
    runAnimationFrameCallbacks();
    paint();
  }
}

Trzy słowa do zapamiętania: task → microtaski → render. W tej kolejności, w każdej iteracji.

Łatwiej to zobaczyć niż przeczytać. Poniżej jeden pełny obrót event loopa krok po kroku - włącznie z momentem, gdy microtask dodaje kolejny microtask podczas opróżniania kolejki. Ten dodany też zostanie wykonany w tym samym cyklu, zanim render dostanie szansę:

Jeden pełny obrót event loopa
TASKMICROTASKSRENDER
Microtask queue0
pusta
Task queue2
task1()
task2()
Konsola
Krok 1/8
Stan startowy: w task queue czekają dwa taski (task1, task2). Microtask queue jest pusta.

Co z tego wynika: między każdym taskiem silnik wykonuje wszystkie pending microtaski. Jeśli task zaplanował 5 microtasków - wszystkie 5 wykonają się przed kolejnym taskiem. Jeśli każdy z tych 5 zaplanował kolejny microtask - te też pójdą w tej samej iteracji. Microtask queue opróżnia się całkowicie, włącznie z microtaskami dodanymi podczas opróżniania.

Stąd właśnie nieintuicyjny output 1, 4, 3, 2: callback z Promise.then zawsze wykonuje się przed callbackiem z setTimeout, nawet jeśli setTimeout był pierwszy w kodzie. Microtaski wyprzedzają taski z task queue, niezależnie od kolejności w kodzie.

#Render step - gdzie wpasowuje się requestAnimationFrame

„Render” to moment, gdy przeglądarka maluje pixele na ekranie. Jeśli JavaScript zmienił coś w DOM (kolor, treść, pozycję, klasę CSS), zmiana pojawi się dopiero gdy event loop dojdzie do render step. Ilekroć w tym poście widzisz słowo „render” - chodzi dokładnie o ten moment. console.log() nie wymaga renderu - konsola w DevTools to osobny mechanizm, nie część rysowania strony.

Render odpala się typowo ~60 razy na sekundę (16.7ms na klatkę), zsynchronizowany z odświeżaniem monitora. Każdy cykl event loopa: task → microtaski → ewentualnie render → następny task. I tak w kółko.

Do animacji używasz requestAnimationFrame (skracane jako rAF), nie setTimeout(0):

requestAnimationFrame(() => {
  element.style.transform = "translateX(100px)";
});

rAF rejestruje callback tuż przed namalowaniem kolejnej klatki - zmiana DOM trafia na ekran w tej samej klatce, bez rozjazdu z odświeżaniem. setTimeout(0) ląduje w zwykłej task queue (i nie jest zsynchronizowany z ekranem), więc callback może odpalić się między klatkami i animacja będzie szarpać.

Skutek kolejności task → microtaski → render dla zmian widocznych na ekranie:

  • Zmiana DOM w microtasku (np. callback z Promise.then) → widoczna w bieżącej klatce. Microtask wykonuje się przed renderem w tym samym cyklu.
  • Zmiana DOM w kolejnym tasku (np. callback z setTimeout(0)) → widoczna dopiero w następnej klatce. Nowy task to nowy cykl event loopa, z własnym renderem na końcu.

#Rozłóżmy otwarcie na czynniki pierwsze

Wracamy do oryginalnego kodu:

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

Aplikujemy schemat z poprzedniej sekcji (TASK → MICROTASKS → RENDER):

  1. Cały skrypt jest jednym taskiem. Wykonuje się od góry do dołu na stacku.
  2. console.log("1") - drukuje. Konsola: 1.
  3. setTimeout(callback, 0) - silnik rejestruje callback w Web APIs. Po opóźnieniu przeglądarka wkłada go do task queue.
  4. Promise.resolve().then(callback) - silnik wkłada callback od razu do microtask queue.
  5. console.log("4") - drukuje. Konsola: 1, 4.
  6. Skrypt-task się skończył. Faza MICROTASKS: event loop opróżnia microtask queue. Wykonuje się callback z Promise.then - wypisuje 3. Konsola: 1, 4, 3.
  7. Microtask queue pusta. Następna iteracja - faza TASK bierze callback z task queue. Wykonuje się - wypisuje 2. Konsola: 1, 4, 3, 2.

Kluczowe: między krokiem 5 a 7 microtask queue zostaje opróżniona w całości, zanim event loop tknie task queue. Dlatego 3 wyprzedza 2, choć setTimeout był wcześniej w kodzie.

#Pułapki praktyczne

#Microtask starvation - jak zagłodzić render

Jeśli microtask planuje kolejny microtask, kolejka nigdy się nie opróżnia. Render się nie odbywa, UI zamarza:

function loop() {
  Promise.resolve().then(loop); // ❌ blokuje render w nieskończoność
}
loop();

Każde wywołanie loop() dodaje kolejne loop() do microtask queue. Event loop nigdy nie skończy opróżniania, nigdy nie dojdzie do renderu, strona przestaje reagować.

Rozwiązanie: planuj powtórzenie jako task (setTimeout(0)), nie microtask. Wtedy między iteracjami event loop oddycha - render się odbywa, kliknięcia się rejestrują.

#await w pętli vs Promise.all

Klasyczny błąd:

// ❌ Sequential - każdy fetch czeka na poprzedni
for (const url of urls) {
  const data = await fetch(url); // ~100ms × N requestów
}
// ✅ Parallel - wszystkie fetch'e startują równocześnie
const results = await Promise.all(urls.map((url) => fetch(url)));
// ~100ms total

W wersji sequential każde await zatrzymuje funkcję. Kontynuacja po await ląduje w microtask queue dopiero po otrzymaniu odpowiedzi - więc kolejny fetch nawet nie startuje, dopóki poprzedni nie wróci.

W wersji parallel wszystkie fetch’e startują równocześnie. Przeglądarka prowadzi je obok siebie (każdy request to osobne połączenie sieciowe). Promise.all zwraca dopiero, gdy wszystkie się rozstrzygną.

await w pętli0 ms

Każdy fetch czeka na poprzedni.

fetch 1
0%
fetch 2
0%
fetch 3
0%
Razem~1800 ms
Promise.all0 ms

Wszystkie fetch'e startują równocześnie.

fetch 1
0%
fetch 2
0%
fetch 3
0%
Razem~600 ms

Lewa strona dobija do ~1800ms (3 × 600ms po kolei). Prawa kończy w ~600ms. Różnica nie wynika z tego, że event loop nagle wykonuje rzeczy równolegle - tylko z tego, że przeglądarka (host) potrafi obsługiwać wiele requestów jednocześnie. await w pętli świadomie tę zdolność wyrzuca.

#Long sync work blokuje event loop

function expensiveOperation() {
  for (let i = 0; i < 1_000_000_000; i++) {
    /* CPU work */
  }
}

expensiveOperation(); // ❌ blokuje event loop na sekundy

Podczas synchronicznej pętli call stack nie jest pusty. Event loop nie weźmie żadnego taska, microtaska ani renderu. UI zamarza, kliknięcia się nie rejestrują, animacje stoją.

Dwa rozwiązania:

  • Chunking - rozbij pracę na fragmenty rozdzielone setTimeout(0). Każdy fragment to osobny task, więc między nimi event loop oddycha: opróżnia microtaski, robi render, łapie kliknięcia.
  • Web Workers - dla naprawdę dużej pracy CPU. Worker to osobny wątek z własnym event loop, komunikuje się z głównym przez postMessage. Wątek główny zostaje wolny dla UI.

Pułapka: queueMicrotask nie jest rozwiązaniem na długą pracę. Microtaski wyprzedzają render, więc queueMicrotask(longWork) zablokuje malowanie klatki dokładnie tak samo, jak wywołanie synchroniczne. To narzędzie do odłóż na koniec bieżącego taska, ale przed renderem - dla krótkich operacji, nie dla obliczeń.

#Mental model

Event loop sprowadza się do trzech rzeczy:

  1. Jest jeden wątek. Wszystko, co wygląda na „równoległe” w JavaScripcie, jest sekwencyjne - tylko sprytnie poukładane przez event loop.
  2. Microtaski wyprzedzają taski. Po każdym tasku event loop opróżnia całą microtask queue do dna, dopiero potem przechodzi do renderu i kolejnego taska.
  3. Render to specjalny task uruchamiany przez przeglądarkę w rytm odświeżania ekranu. Może być zablokowany przez microtask starvation - microtaski w pętli planujące kolejne microtaski, więc kolejka nigdy się nie kończy i render nie dostaje szansy.

Gdy widzisz nieintuicyjny kod async, zadaj sobie trzy pytania:

  1. Co tu jest synchroniczne, a co asynchroniczne? Synchroniczne idzie na stack od razu. Asynchroniczne (setTimeout, fetch, Promise.then) trafia do hosta lub kolejki.
  2. W którą kolejkę idzie callback? Promise / await / queueMicrotask → microtask queue. setTimeout / DOM events / network → task queue.
  3. Kiedy stack będzie pusty? Bo dopiero wtedy event loop weźmie cokolwiek z kolejek.

Wracamy do 1, 4, 3, 2, w trzech ruchach:

  1. Skrypt to task - silnik wypisuje 1 i 4.
  2. Microtask checkpoint - wypisuje 3.
  3. Kolejna iteracja, kolejny task z task queue - wypisuje 2.

Trzy fazy, jeden algorytm. Zaprojektowany tak, żeby był przewidywalny - i właśnie taki jest, gdy znasz reguły.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz