Pętla zdarzeń w JavaScript — jak naprawdę działa asynchroniczność

Zrozum Event Loop bez skrótów myślowych. Call Stack, taski, microtaski, await, timery i renderowanie wyjaśnione tak, żeby dało się to później obronić na rozmowie i w praktyce.

Opublikowano

8 lipca 2025 11:20

Czytanie

7 min czytania

Aktualizacja

15 kwietnia 2026 11:52

"Wyjaśnij proszę Event Loop" to jedno z tych pytań, które bardzo często wracają na rozmowach technicznych dotyczących JavaScript. I bardzo często odpowiedź kończy się na skrócie: "JavaScript jest jednowątkowy, więc ma kolejkę...".

Ale co to naprawdę znaczy? Dlaczego setTimeout(fn, 0) nie wykonuje się natychmiast? Dlaczego Promise rozwiązuje się przed setTimeout? Czym są microtasks?

W tym artykule wyjaśnię Event Loop od podstaw, z wizualizacjami i przykładami, które rozwiązują zagadki asynchroniczności.

Krótka odpowiedź: JavaScript jest jednowątkowy — w danym momencie robi jedną rzecz. Event Loop obsługuje asynchroniczność: opróżnia Call Stack → wykonuje WSZYSTKIE microtasks (Promise.then, await) → wykonuje JEDEN task (setTimeout, event handler) → powtarza. Dlatego Promise.resolve().then(fn) wykonuje się PRZED setTimeout(fn, 0) — microtasks mają wyższy priorytet niż tasks. Długie operacje synchroniczne blokują Event Loop — dla ciężkich obliczeń używaj Web Workers.

JavaScript jest jednowątkowy

To fundament. JavaScript ma jeden główny wątek wykonawczy dla danego kontekstu — w danym momencie Twój kod JS robi jedną rzecz naraz.

Code
// Te operacje wykonują się sekwencyjnie
console.log('Pierwsza')
console.log('Druga')
console.log('Trzecia')
// Wynik: Pierwsza, Druga, Trzecia — zawsze w tej kolejności

To nie znaczy, że całe środowisko ma fizycznie jeden wątek. Timery, sieć, I/O czy rendering są obsługiwane przez runtime i przeglądarkę poza samym stosem wywołań JavaScript.

Ale jak w takim razie możemy mieć asynchroniczne operacje? Jak przeglądarka obsługuje kliknięcia, fetch, setTimeout — jednocześnie?

Odpowiedź: JavaScript runtime to nie tylko silnik JS.

Architektura JavaScript Runtime

Code
┌─────────────────────────────────────────────────────────┐
│                     JavaScript Runtime                   │
│                                                          │
│  ┌──────────────┐     ┌─────────────────────────────┐   │
│  │   Call Stack │     │        Web APIs             │   │
│  │              │     │  (setTimeout, fetch, DOM)   │   │
│  │  [funkcja]   │ ──▶ │                             │   │
│  │  [funkcja]   │     │                             │   │
│  └──────────────┘     └─────────────────────────────┘   │
│         ▲                          │                     │
│         │                          ▼                     │
│         │  ① ┌───────────────────────────┐              │
│         │    │  Microtask Queue (wyższy  │              │
│         │    │  priorytet)               │              │
│         │    │  [promise] [promise]      │              │
│         │    └───────────────────────────┘              │
│         │                          │                     │
│         │  ② ┌───────────────────────────┐              │
│         │    │  Task Queue (niższy       │              │
│         │    │  priorytet)               │              │
│         │    │  [callback] [callback]    │              │
│         │    └───────────────────────────┘              │
│         │                          │                     │
│         │    ┌─────────────────────┘                    │
│         │    │                                          │
│         │    ▼                                          │
│    ┌────────────────┐                                   │
│    │   Event Loop   │                                   │
│    └────────────────┘                                   │
│                                                          │
└─────────────────────────────────────────────────────────┘

Elementy runtime:

  1. Call Stack — stos wywołań, gdzie wykonuje się kod
  2. Web APIs / runtime APIs — środowisko przeglądarki lub Node.js (timery, HTTP, DOM events, I/O)
  3. Callback Queue (Task Queue) — kolejka callbacków z setTimeout, setInterval, events
  4. Microtask Queue — kolejka dla Promise, queueMicrotask, MutationObserver
  5. Event Loop — mechanizm przenoszący zadania z kolejek na stos

W przeglądarce zwykle mówi się o Web APIs. W Node.js analogiczną rolę pełnią timery, I/O i mechanizmy event loop oparte o libuv, więc szczegóły runtime'u trochę się różnią.

Call Stack — stos wywołań

Call Stack działa jak stos talerzy: ostatni włożony = pierwszy zdjęty (LIFO).

Code
function trzecia() {
  console.log('Trzecia')
}
 
function druga() {
  trzecia()
  console.log('Druga')
}
 
function pierwsza() {
  druga()
  console.log('Pierwsza')
}
 
pierwsza()

Przebieg Call Stack:

Code
1. [pierwsza]
2. [pierwsza, druga]
3. [pierwsza, druga, trzecia]
4. [pierwsza, druga]           // trzecia zakończona, log: "Trzecia"
5. [pierwsza]                   // druga zakończona, log: "Druga"
6. []                           // pierwsza zakończona, log: "Pierwsza"

Wynik: "Trzecia", "Druga", "Pierwsza"

Web APIs — asynchroniczne operacje

Gdy wywołujesz setTimeout, fetch czy addEventListener, JavaScript przekazuje zadanie do Web APIs przeglądarki:

Code
console.log('Start')
 
setTimeout(() => {
  console.log('Timeout')
}, 1000)
 
console.log('Koniec')

Przebieg:

Code
1. Call Stack: [console.log('Start')]  → wyświetla "Start"
2. Call Stack: [setTimeout]            → przekazuje do Web APIs
3. Web APIs: timer 1000ms (działa w tle)
4. Call Stack: [console.log('Koniec')] → wyświetla "Koniec"
5. Call Stack: [] (pusty)
6. ...1000ms mija...
7. Web APIs → callback do Task Queue
8. Event Loop: stack pusty? Tak → przenieś callback
9. Call Stack: [callback]              → wyświetla "Timeout"

Wynik: "Start", "Koniec", "Timeout"

Event Loop — serce asynchroniczności

Event Loop to prosty algorytm działający w nieskończonej pętli:

Code
while (true) {
  1. Wykonuj kod z Call Stack aż będzie pusty
  2. Sprawdź Microtask Queue — wykonaj WSZYSTKIE microtasks
  3. Sprawdź Task Queue — wykonaj JEDEN task
  4. Runtime może dostać okazję do renderowania lub innych prac systemowych
  5. Wróć do kroku 1
}

Kluczowe:

  • Microtasks mają priorytet nad tasks
  • Wszystkie microtasks wykonują się przed kolejnym taskiem
  • Przeglądarka może renderować między taskami, jeśli główny wątek jest wolny

Task Queue vs Microtask Queue

To kluczowa różnica, która często pojawia się na rozmowach.

Task Queue (Callback Queue)

Trafiają tu:

  • setTimeout / setInterval callbacks
  • Event handlers (click, keydown)
  • I/O operations

Microtask Queue

Trafiają tu:

  • Promise.then() / catch() / finally()
  • queueMicrotask()
  • MutationObserver
  • await (kod po każdym await)

Klasyczny test

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

Zgadnij wynik przed czytaniem dalej!

Przebieg:

Code
1. Synchroniczny: console.log('1')  → "1"
2. setTimeout → callback do Task Queue
3. Promise.then → callback do Microtask Queue
4. Synchroniczny: console.log('4')  → "4"
5. Call Stack pusty → sprawdź Microtask Queue
6. Microtask: console.log('3')      → "3"
7. Microtask Queue pusta → sprawdź Task Queue
8. Task: console.log('2')           → "2"

Wynik: 1, 4, 3, 2

Promise (microtask) wykonał się przed setTimeout (task), mimo że oba były "asynchroniczne".

Łańcuch microtasks

Łańcuchowane .then() dodają kolejne microtasks do kolejki — i wszystkie wykonają się przed kolejnym taskiem:

Code
console.log('Start')
 
setTimeout(() => console.log('Timeout'), 0)
 
Promise.resolve()
  .then(() => {
    console.log('Promise 1')
    return Promise.resolve()
  })
  .then(() => {
    console.log('Promise 2')
  })
 
console.log('End')

Wynik: Start, End, Promise 1, Promise 2, Timeout

Wszystkie Promise (microtasks) wykonały się przed setTimeout (task).

setTimeout(fn, 0) — dlaczego nie od razu?

Code
console.log('Przed')
setTimeout(() => console.log('Timeout'), 0)
console.log('Po')
 
// Wynik: Przed, Po, Timeout

setTimeout(fn, 0) nie oznacza "wykonaj natychmiast". Oznacza "dodaj do Task Queue najszybciej jak to możliwe". Ale callback musi czekać aż:

  1. Call Stack będzie pusty
  2. Wszystkie microtasks się wykonają
  3. Event Loop przeniesie go na stos

Nie ma gwarancji, że callback wykona się "natychmiast po 0 ms". Przeglądarki mogą opóźniać timery, a w nieaktywnych kartach te opóźnienia bywają dużo większe. Specyfikacja definiuje minimum 4 ms dla odpowiednio zagnieżdżonych timerów.

async/await i Event Loop

async/await to syntactic sugar dla Promise. Kod po await trafia do Microtask Queue:

Code
async function example() {
  console.log('1 - przed await')
  await Promise.resolve()
  console.log('2 - po await')
}
 
console.log('3 - start')
example()
console.log('4 - end')

Przebieg:

Code
1. Synchroniczny: console.log('3 - start')  → "3 - start"
2. Wywołaj example()
3. Synchroniczny (w example): console.log('1 - przed await')  → "1 - przed await"
4. await → reszta funkcji do Microtask Queue
5. Synchroniczny: console.log('4 - end')  → "4 - end"
6. Call Stack pusty → Microtask Queue
7. Microtask: console.log('2 - po await')  → "2 - po await"

Wynik: 3 - start, 1 - przed await, 4 - end, 2 - po await

Blocking Event Loop — czego unikać

Ponieważ JavaScript jest jednowątkowy, długie operacje blokują wszystko:

Code
// ❌ Blokuje Event Loop na ~5 sekund
function heavyComputation() {
  const start = Date.now()
  while (Date.now() - start < 5000) {
    // busy waiting
  }
}
 
heavyComputation() // strona "zamarza"

W tym czasie:

  • Żadne kliknięcia nie będą obsłużone
  • Żadne animacje się nie wykonają
  • Strona jest "zamrożona"

Rozwiązania

1. Podziel pracę na chunki:

Code
function processChunk(items, index, callback) {
  const chunkSize = 100
  const end = Math.min(index + chunkSize, items.length)
 
  for (let i = index; i < end; i++) {
    // przetwarzaj item
  }
 
  if (end < items.length) {
    setTimeout(() => processChunk(items, end, callback), 0)
  } else {
    callback()
  }
}

2. Web Workers (osobny wątek):

Code
// main.js
const worker = new Worker('worker.js')
worker.postMessage(largeData)
worker.onmessage = (e) => console.log(e.data)
 
// worker.js
onmessage = (e) => {
  const result = heavyComputation(e.data)
  postMessage(result)
}

Wizualizacja Event Loop

Polecam narzędzie Loupe — wizualizuje Event Loop w czasie rzeczywistym. Wklej kod i obserwuj jak przepływa przez Call Stack, Web APIs i kolejki.

Pytania z rozmów kwalifikacyjnych

Pytanie 1: Co wyświetli ten kod?

Code
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
setTimeout(() => console.log('D'), 0)
Promise.resolve().then(() => console.log('E'))
console.log('F')

Odpowiedź: A, F, C, E, B, D

Pytanie 2: Co wyświetli ten kod?

Code
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
 
async function async2() {
  console.log('async2')
}
 
console.log('script start')
setTimeout(() => console.log('setTimeout'), 0)
async1()
console.log('script end')

Odpowiedź: script start, async1 start, async2, script end, async1 end, setTimeout

Pytanie 3: Jak sprawić, żeby setTimeout wykonał się przed Promise?

Odpowiedź: W tym samym ticku i przy standardowych mechanizmach planowania microtasks mają priorytet nad tasks. Jeśli oba callbacki są już zakolejkowane, Promise.then() wykona się przed setTimeout(..., 0).

FAQ

Co to jest Event Loop w JavaScript i jak działa?

Event Loop to mechanizm zarządzający asynchronicznością w JavaScript. Działa w pętli: (1) wykonaj cały synchroniczny kod na Call Stack, (2) wykonaj wszystkie microtasks (Promise, await) z Microtask Queue, (3) wykonaj jeden task z Task Queue (setTimeout, event handler), (4) wróć do kroku 1. Dzięki temu JavaScript może obsługiwać asynchroniczne operacje mimo bycia jednowątkowym — ciężka praca (sieć, timery) odbywa się poza stosem wywołań, a wyniki wracają przez kolejki.

Dlaczego Promise wykonuje się przed setTimeout(fn, 0)?

Promise callbacks trafiają do Microtask Queue, a setTimeout callbacks — do Task Queue. Event Loop zawsze opróżnia CAŁĄ Microtask Queue przed wykonaniem kolejnego tasku. Dlatego Promise.resolve().then(() => console.log('A')) wykona się przed setTimeout(() => console.log('B'), 0) — nawet jeśli oba są "asynchroniczne" i oba mają minimalny czas oczekiwania. To najczęstsze pytanie o Event Loop na rozmowach technicznych.

Jaka jest różnica między Task Queue a Microtask Queue?

Task Queue (Callback Queue): trafiają tu callbacki z setTimeout, setInterval, event handlers (click, keydown), I/O. Microtask Queue: trafiają tu Promise.then/catch/finally, queueMicrotask(), MutationObserver, kod po każdym await. Kluczowa różnica: przed każdym nowym taskiem Event Loop opróżnia CAŁĄ Microtask Queue. Jeden task → wszystkie microtasks → następny task. Microtasks zawsze mają priorytet.

Co to jest Call Stack i jak wpływa na wykonanie kodu?

Call Stack to stos wywołań działający na zasadzie LIFO (last in, first out) — ostatnia dodana funkcja jest pierwszą zakończoną. Gdy wywołujesz funkcję, trafia na stos. Gdy kończy wykonanie, jest zdejmowana. Synchroniczny kod wykonuje się na stosie. Event Loop przenosi kolejny task na stos tylko gdy jest on pusty. Dlatego długa operacja synchroniczna (pętla przez 5 sekund) blokuje całą stronę — nic nie może wejść na stos.

Co to jest setTimeout(fn, 0) i dlaczego callback nie wykonuje się natychmiast?

setTimeout(fn, 0) nie znaczy "wykonaj natychmiast" — znaczy "dodaj do Task Queue najszybciej jak to możliwe". Callback musi czekać aż: Call Stack będzie pusty, wszystkie microtasks się wykonają, Event Loop przeniesie go na stos. Przeglądarki mają też minimalny czas opóźnienia (~4ms dla zagnieżdżonych timerów), a w nieaktywnych kartach timery mogą być throttlowane do 1000ms. Używaj setTimeout(fn, 0) do odkładania kodu na następny tick, nie do natychmiastowego wykonania.

Jak unikać blokowania Event Loop w JavaScript?

Długie operacje synchroniczne blokują Event Loop — strona "zamarza". Rozwiązania: (1) podziel pracę na chunki z setTimeout(() => processNextChunk(), 0) — oddajesz kontrolę Event Loop między partiami; (2) użyj Web Workers do ciężkich obliczeń w osobnym wątku (Worker nie blokuje głównego wątku); (3) użyj requestIdleCallback do niskopriorytycznej pracy w czasie bezczynności; (4) operacje I/O (fetch, baza danych) są zawsze asynchroniczne — nie blokują.

Jak async/await wpisuje się w model Event Loop?

async/await to syntactic sugar dla Promise — nie zmienia modelu Event Loop. Kod po await trafia do Microtask Queue, tak jak Promise.then(). Gdy silnik napotka await, zatrzymuje wykonanie funkcji async, odkłada resztę jako microtask i wraca do Call Stack. Praktyczna konsekwencja: const result = await fetch(url) nie blokuje wątku — po await kod czeka w Microtask Queue i wznawia gdy Promise się rozwiąże.

Źródła i dokumentacja

Podsumowanie

PojęcieOpis
Call StackGdzie kod się wykonuje (LIFO)
Web APIsŚrodowisko przeglądarki dla async operacji
Task QueuesetTimeout, events — jeden task na tick
Microtask QueuePromise, await — wszystkie przed next task
Event LoopPrzenosi zadania z kolejek na Call Stack

Kluczowe zasady:

  1. JavaScript jest jednowątkowy
  2. Microtasks mają priorytet nad tasks
  3. WSZYSTKIE microtasks wykonują się przed kolejnym taskiem
  4. Długie operacje blokują Event Loop

Zrozumienie Event Loop to fundament pisania wydajnego, asynchronicznego JavaScript. Na rozmowie pokaż, że rozumiesz nie tylko "co" się dzieje, ale "dlaczego".


Przygotowujesz się do rozmowy technicznej? Dołóż do tego jeszcze scope, closures, this i prototypy — razem z Event Loop tworzą bardzo częsty zestaw pytań rekrutacyjnych.

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab