Pętla zdarzeń JavaScript napędzająca Twoją stronę internetową zachowuje się bardzo inaczej niż ta, która zasila serwer Node.js, a ta niezgodność może zamrozić interfejs użytkownika (UI) lub zapchać operacje I/O, jeśli nie zachowasz ostrożności. Wiedza o tym, w których miejscach te dwa środowiska się różnią, jest niezbędna dla każdego, kto pisze kod asynchroniczny uruchamiany w obu tych środowiskach.

Dlaczego to rozróżnienie jest ważne

Pętla zdarzeń nie jest zdefiniowana w specyfikacji ECMAScript; istnieje ona w środowisku hosta. Przeglądarki muszą zachować responsywność strony podczas renderowania klatek, podczas gdy Node.js jest zbudowany wokół nieblokującego I/O. Mieszanie wzorców, które działają w jednym środowisku, z drugim, może prowadzić do błędów trudnych do powtórzenia: długi łańcuch obietnic (promises) może wstrzymać odświeżanie (repaint) przeglądarki, podczas gdy niekontrolowana pętla process.nextTick może uniemożliwić Node.js dotarcie do faz I/O.

Przeglądarkowa pętla oparta na turach

W przeglądarce pętla wykonuje pojedynczy cykl, który przeplata wykonywanie zadań, opróżnianie mikrozadań (microtask draining) i renderowanie:

  1. Wykonaj jedno makrozadanie (macrotask) (np. handler kliknięcia, setTimeout itp.).
  2. Opróżnij wszystkie mikrozadania (microtasks) (obietnice/promises, queueMicrotask).
  3. Jeśli nadszedł czas na klatkę, wykonaj malowanie (paint) i kompozycję (composite), aby osiągnąć docelowe 60 fps.
  4. Powtórz.

Dwa API dają programistom jawne punkty zaczepienia (hooks) w tym cyklu:

  • requestAnimationFrame – wywoływany tuż przed malowaniem przez przeglądarkę. To właściwe miejsce na prace związane z animacją, ponieważ callback wykonuje się po bieżących mikrozadaniach, ale przed następną klatką.
  • requestIdleCallback – wywoływany, gdy przeglądarka nie ma zadań o wysokim priorytecie. Jest przydatny do zadań o niskim wpływie na wydajność, takich jak analityka czy wstępne ładowanie danych.

Pułapka: zagłodzenie mikrozadań

Ponieważ przeglądarka opróżnia kolejkę mikrozadań zanim dokona renderowania, długi ńańcuch obietnic (promises) może sprawić, że interfejs użytkownika nigdy nie zostanie odświeżony. Stos wywołań (call stack) nie jest blokowany; strona po prostu nigdy nie dociera do kroku renderowania, co dla użytkownika wygląda jak zawieszenie aplikacji.

Pętla Node.js napędzana przez libuv

Node.js deleguje swoją pętlę do libuv, biblioteki C, która dzieli pracę na odrębne fazy, z których każda posiada własną kolejkę:

  1. Timers – callbacki z setTimeout i setInterval.
  2. Pending callbacks – odroczone callbacki I/O, które zostały już zakończone na poziomie systemu operacyjnego.
  3. Poll – pobiera nowe zdarzenia I/O (odczyty plików, dane sieciowe).
  4. Check – wykonuje callbacki setImmediate.
  5. Close callbacks – uruchamia się, gdy gniazdo (socket) lub uchwyt (handle) zostaje zamknięty.

Dwa elementy znajdują się poza tą kolejnością faz:

  • process.nextTick – wykonuje się przed kolejką mikrozadań, natychmiast po zakończeniu bieżącej operacji.

Pułapka: zagłodzenie I/O

Jeśli funkcja wielokrotnie szereguje process.nextTick bez oddawania kontroli, Node nigdy nie przejdzie dalej niż do kroku „next-tick”. Zapytania sieciowe, odczyty plików i timery pozostają bezczynne, co powoduje skoki opóźnień po stronie serwera lub całkowite zawieszenie aplikacji.

setImmediate vs. setTimeout w praktyce

Obie funkcje szeregują callbacki dla następnej iteracji, ale ich względna kolejność zależy od miejsca, w którym są wywoływane:

  • Kod na najwyższym poziomie – kolejność nie jest gwarantowana; zależy ona od tego, jak szybko uruchomi się proces.
  • Wewnątrz callbacku I/O – kolejność jest deterministyczna: setImmediate wykonuje się przed setTimeout(fn, 0). Po zakończeniu fazy Poll, libuv przechodzi do fazy Check (w której znajduje się setImmediate), zanim ponownie wejdzie w fazę Timers dla timeoutu z zerowym opóźnieniem.

Ta subtelność ma znaczenie, gdy polegasz na precyzyjnej sekwencji, np. podczas czyszczenia zasobu natychmiast po zakończeniu odczytu.

Kluczowe różnice na pierwszy rzut oka

  • Cel: Przeglądarki priorytetyzują aktualizacje wizualne; Node priorytetyzuje gotowość I/O.
  • Hook renderowania: requestAnimationFrame (tylko przeglądarka).
  • Hook specyficzny dla fazy: setImmediate (tylko Node, uruchamiany w fazie Check).
  • Kolejka o wysokim priorytecie: process.nextTick (tylko Node, uruchamiany przed mikrozadaniami).
  • Ryzyko zagłodzenia: Długie łańcuchy obietnic w przeglądarkach; nieograniczone process.nextTick w Node.

Na co zwrócić uwagę w przyszłości

Jeśli utrzymujesz kod źródłowy działający w obu środowiskach (np. biblioteki izomorficzne), sprawdź każde miejsce, w którym:

  • Łączysz wiele obietnic (promises) bez oddawania kontroli pętli zdarzeń. Wstaw await new Promise(r => setTimeout(r, 0)) lub użyj requestIdleCallback w przeglądarce, aby dać rendererowi szansę.
  • Używasz process.nextTick do zada

Kluczowe wnioski

Event loop to scheduler specyficzny dla środowiska uruchomieniowego, a nie uniwersalna funkcja języka JavaScript. Przeglądarki wplatają renderowanie w pętlę, podczas gdy Node izoluje operacje I/O w fazach libuv. Niewłaściwe użycie mechanizmów priorytetów — mikrozadań (microtasks) w przeglądarce czy process.nextTick w Node — może doprowadzić do „zagłodzenia” tej części systemu, której dane środowisko ma służyć. Dostosowując wzorce asynchroniczne do modelu pętli danego środowiska, unikniesz zarówno zawieszania się stron, jak i blokowania serwerów.