Promises w JavaScript - od callback hell do microtask queue

Async w JS bez Promise to callback hell - kod nie ma jak odwoływać się do trwającej operacji, a obsługa błędów siedzi osobno w każdym callbacku. Promise to obietnica wartości, która przyjdzie później - kod układa się płasko, a błędy łapiesz w jednym miejscu.
Asynchroniczny JavaScript jest czymś, co zwykle traktujemy jak coś, co po prostu działa. Wpisujemy await fetch(...), dostajemy odpowiedź, idziemy dalej. Nie zastanawiamy się, gdzie konkretnie ta funkcja “spała”, co czekało w kolejce, ani dlaczego wynik jest dostępny w kolejnej linijce, choć request właśnie poleciał do internetu.
Pod spodem za to wszystko odpowiada jeden obiekt - Promise. Nie marketingowe hasło, tylko konkretny kawałek silnika, który trzyma miejsce dla wartości, której jeszcze nie ma. Ten post jest o tym, jak to miejsce się trzyma, kiedy się je wymienia na realną wartość i dlaczego callback z .then() odpala się wcześniej niż callback z setTimeout(0).
#Życie przed Promise
Żeby zrozumieć, co Promise załatwił, warto przypomnieć, jak pisało się kod asynchroniczny zanim Promise pojawił się w języku - bo to był nie tylko brzydszy zapis, tylko inny sposób patrzenia na to, jak JS rozmawia z przeglądarką.
Pierwszy problem: większość operacji asynchronicznych nie działa w JavaScripcie. Network request, timer, nasłuch na klik - to wszystko siedzi w przeglądarce (a konkretnie w Web API). JS zleca, przeglądarka robi, callback wraca. Z perspektywy języka: dałem coś do zrobienia i nie mam pojęcia, co się tam dzieje. Mogę dać callback i czekać, aż przeglądarka go odpali - ale dopóki tego nie zrobi, w samym JS nie ma nic. Żadnej referencji, żadnego obiektu, żadnego identyfikatora, którego mógłbym śledzić.
console.log("start");
setTimeout(() => console.log("po sekundzie"), 1000);
console.log("dalej"); setTimeout zwraca id timera, ale to id jest po stronie przeglądarki. W JS nie mamy dostępu do “tego, co się dzieje przez sekundę” - mamy tylko wiarę, że za sekundę przyleci callback. To nie jest błąd projektowy języka; to po prostu konsekwencja faktu, że pętla zdarzeń (event loop) i większość Web API są poza JS-em.
Drugi problem to ułożenie kilku operacji w kolejność. Gdy potrzebujesz zrobić request, zaczekać na wynik, zrobić drugi request zależny od pierwszego, a potem trzeci - kończysz w piramidzie zwanej callback hell:
// Pobierz użytkownika → jego posty → komentarze do pierwszego posta
getUser(
1,
(user) => {
getPosts(
user.id,
(posts) => {
getComments(
posts[0].id,
(comments) => {
render(comments);
},
errComments,
);
},
errPosts,
);
},
errUser,
);
// trzy ścieżki błędu, trzy poziomy wcięcia, zero szans na try/catchKażdy poziom zagnieżdżenia to nowy callback i osobny handler błędu. Spróbuj złapać wyjątek z trzeciego requesta na poziomie pierwszego - musisz ręcznie połączyć trzy ścieżki błędu albo zrobić wspólny handleError, który widzi wszystkie konteksty. Trywialny refaktor (np. podzielić to między pliki) staje się ryzykowny, bo całość trzyma się ukrytymi closure’ami - każdy callback pamięta zmienne ze swojego rodzica, ale ty nie widzisz tego mechanizmu w kodzie.
Trzeci problem - obsługa błędów - jest osobnym piekłem. try/catch nie łapie błędów z callbacków, bo callback już dawno wyszedł ze stosu wywołań w momencie, gdy się wykona. Każdy poziom musi mieć swój własny onerror, a jeśli zapomnisz - błąd po cichu znika.
#Co właściwie zwraca fetch()
ES6 wprowadził Promise. fetch('https://api.github.com/users/octocat') zwraca dokładnie taki obiekt - istnieje od razu, ale startuje w stanie pending, bo wartość dopiero ma przyjść. Klikaj poniżej, żeby zobaczyć przejście do fulfilled (sukces, jest wartość) albo rejected (porażka, jest powód błędu):
Kliknij przycisk, żeby zobaczyć przejście stanu Promise.
To obiekt zastępczy: jest natychmiast, ma identyfikowalny stan, można go przypisać do zmiennej, przekazać dalej, podpiąć do niego handlery. A kiedy operacja się zakończy - sam zmieni stan i ujawni wynik.
Tu pojawia się pojęcie, które na wykładach Willa Sentance’a nazywa się dwustronną fasadą (“two-pronged facade function”). Zwykła funkcja w JS robi coś po stronie JS-a i zwraca wynik. fetch robi dwie rzeczy naraz: po stronie przeglądarki startuje sieciowy request (rzecz, której JS nie potrafi sam zrobić), a po stronie JS-a od razu zwraca obiekt, którego stan będzie odzwierciedlać to, co zrobi przeglądarka. To pierwszy raz, gdy JS dostał obiekt reprezentujący operację asynchroniczną zainicjowaną przez przeglądarkę. Wcześniej był ślepy: dawał przeglądarce zadanie i czekał na callback, nie mając się jak do niej odwołać.
To zmienia narrację. Zamiast “zlecam i czekam, aż mnie ktoś znajdzie” mamy “zlecam, dostaję obiekt-pośrednik i robię z nim co chcę”.
#Trzy stany i jeden bilet w jedną stronę
Promise jest w jednym z trzech stanów:
- pending - operacja trwa, wartości nie ma
- fulfilled - operacja się udała, jest wartość (
value) - rejected - operacja się nie udała, jest powód (
reason)
I tu kluczowa rzecz: przejście jest jednorazowe i nieodwracalne. Z pending można przejść do fulfilled albo do rejected, ale dalej już nigdzie - nie ma “spełnij ponownie”, “wycofaj fulfill”, “zmień wartość”. To uproszczenie, które ma swoje konsekwencje (np. dlaczego nie da się anulować Promise - o tym za chwilę).
Stany pending → fulfilled albo pending → rejected nazywa się settling (“ustaleniem”), a sam Promise od momentu rozstrzygnięcia jest settled.
#Anatomia: new Promise(executor)
fetch to gotowiec. Własnego Promise tworzymy ręcznie przez new Promise(...) i podajemy mu jedną funkcję - tzw. executor. Silnik od razu przekaże tej funkcji dwa argumenty: resolve i reject. Wywołasz resolve(wartość) - Promise przejdzie w fulfilled. Wywołasz reject(powód) - przejdzie w rejected.
Najprostszy przykład - Promise, który po sekundzie spełnia się wartością "gotowe":
const p = new Promise((resolve, reject) => {
// udajemy operację, która zajmuje sekundę
setTimeout(() => resolve("gotowe"), 1000);
});
p.then((value) => console.log(value));
// → po sekundzie w konsoli: gotoweCo się tu dzieje krok po kroku:
new Promise(...)tworzy obiektp. Od razu jest w staniepending.- W środku planujemy
setTimeout(() => resolve("gotowe"), 1000)- za sekundę zostanie wywołaneresolve("gotowe"). .then(...)rejestruje callback - “gdy Promise będzie spełniony, daj mi wartość”.- Po sekundzie
setTimeoutodpala swojego callbacka, ten wołaresolve("gotowe"). Promise przechodzi wfulfilled. Callback z.thendostaje wartość i ją wypisuje.
Zauważ, że nie używamy tu reject - bo setTimeout nie ma jak zawieść. Po reject sięgamy dopiero tam, gdzie operacja naprawdę może paść: request sieciowy, odczyt pliku, błąd uprawnień.
Trzy rzeczy, o których łatwo zapomnieć:
- Kod w środku odpala się od razu. Funkcja, którą podajesz do
new Promise(...), wykonuje się synchronicznie, zanim konstruktor cokolwiek zwróci. Asynchroniczne jest dopiero wywołanieresolvelubreject(np. zaplanowane przezsetTimeout). - Drugie
resolve(lubreject) silnik po cichu ignoruje. Promise może przejść zpendingwfulfilledalborejectedtylko raz - po tym przejściu jego stan i wartość są zamrożone. Kolejne wywołaniaresolve/rejectnic nie robią. throww środku działa jakreject. Jeśli wewnątrz tej funkcji rzucisz wyjątek (np.throw new Error("padło")), Promise sam się odrzuci - z tym wyjątkiem jako powodem. Dzięki temu zwykłytry/catchw kodzie zasync/await(o którym za chwilę) w końcu łapie błędy asynchroniczne.
#.then, .catch, .finally
Promise sam w sobie nie jest po nic użyteczny - musimy się jakoś dowiedzieć, kiedy jest gotowy i co ma w środku. Do tego służą trzy metody.
fetch("https://api.github.com/users/octocat")
.then((response) => response.json())
.then((user) => console.log(user.name)) // → "The Octocat"
.catch((err) => console.error("padło:", err))
.finally(() => setLoading(false));.then(onFulfilled, onRejected)- rejestruje callback na moment, gdy Promise będziefulfilled(pierwszy argument) alborejected(drugi argument). Drugi argument działa prawie jak.catch, ale jest tu subtelna różnica: handler błędu w.then(f, r)nie złapie wyjątku rzuconego wewnątrzf, podczas gdy oddzielny.catchpo.then(f)go złapie. To kolejny powód, dla którego praktycznie nikt nie używa drugiego argumentu -.catchna końcu łańcucha jest czytelniejszy i łapie błędy z dowolnego wcześniejszego ogniwa, łącznie z poprzednim.then..catch(onRejected)- skrócony zapis dla.then(undefined, onRejected). Łapie błąd z dowolnego wcześniejszego ogniwa łańcucha..finally(callback)- odpala się niezależnie od wyniku, nie dostaje wartości, nie zmienia stanu. Idealne na cleanup (setLoading(false)itp.).
.then robi dwie rzeczy naraz:
- Callback w środku dostaje wartość, gdy Promise jest gotowy. To “potem” w nazwie
.then. - Samo
.then(...)zwraca nowy Promise - i dlatego można je łączyć w łańcuch jeden po drugim.
To samo z .catch i .finally - każda z nich też zwraca kolejny Promise.
#Chaining - łańcuch zamiast zagnieżdżeń
W callback hell każdy zagnieżdżony request tworzył nowy poziom wcięcia. W Promise robisz to płasko, bo .then zwraca Promise, do którego można dopiąć kolejne .then:
fetch("/api/users/1")
.then((res) => res.json())
.then((user) => fetch(`/api/posts?author=${user.id}`))
.then((res) => res.json())
.then((posts) => render(posts))
.catch((err) => showError(err));
// porównaj z callback hell wyżej:
// trzy zagnieżdżenia → cztery linie obok siebie, jeden .catch dla wszystkichMechanizm: jeśli callback w .then zwróci wartość, kolejne .then dostanie tę wartość. Jeśli zwróci Promise, łańcuch czeka na ten Promise - kolejne .then dostanie jego value, nie sam Promise. To “spłaszczanie” Promise jest tym, co pozwala łańcuchowi wyglądać na płasko, choć każdy krok może być inną asynchroniczną operacją.
Druga zaleta: błędy propagują się przez łańcuch. Jeśli środkowy .then rzuci wyjątek albo zwróci rejected Promise, wszystkie kolejne .then są pominięte i wykonanie skacze do najbliższego .catch. Jeden handler na końcu łańcucha łapie wszystko - i to jest dokładnie to, czego nie dało się sensownie zrobić w callback hell.
#Microtask queue - dlaczego Promise leci PRZED setTimeout(0)
Tu jest jedna rzecz, która zaskakuje większość ludzi przy pierwszym kontakcie:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4"); Najpierw widzisz 1 i 4 - to zwykłe synchroniczne console.log lecące z góry na dół skryptu, więc nie ma w nich nic asynchronicznego. (Dlaczego JS jest synchroniczny i co to dla niego znaczy, opisałem wcześniej w poście o execution context i call stack - tam pokazuję, dlaczego event loop jest jedyną drogą do uruchomienia czegokolwiek poza głównym stackiem.) Niespodzianka jest dalej: kiedy skrypt się skończy, event loop zabiera się za asynchroniczne callbacki - i tu callback z Promise.then (3) odpala się przed callbackiem z setTimeout (2), choć oba mają zerową zwłokę. Dlaczego?
Bo callbacki Promise nie lądują w zwykłej kolejce zadań event loopa (task queue), tylko w osobnej kolejce: microtask queue. I po każdym pojedynczym task’u (np. po wykonaniu skryptu z góry do dołu, po obsłudze eventu, po callbacku z setTimeout) event loop najpierw obsługuje wszystkie microtasks, a dopiero potem bierze kolejny task z task queue.
Klikaj Dalej → / ← Wstecz żeby przeklikać manualnie, albo puść Uruchom i pauzuj w dowolnym momencie:
Praktyczna konsekwencja: jeśli wewnątrz .then zaplanujesz kolejnego .then, on też pójdzie do microtask queue w tej samej rundzie. Dopiero gdy queue jest pusta, event loop przechodzi dalej. To dobrze - bo Promise łańcuchy są “atomowe” względem zewnętrznych zdarzeń. To źle - bo nieskończony łańcuch microtasków potrafi zablokować renderowanie (microtask queue ma priorytet nawet nad rendererem).
Promise nie jest jedynym mechanizmem korzystającym z tej kolejki. Callbacki z queueMicrotask() i MutationObserver trafiają tam dokładnie tak samo - i podlegają tym samym regułom (cała kolejka jest obsługiwana, zanim event loop weźmie kolejny task). Pełną mapę event loop - jak wpasowuje się render step, gdzie czyha microtask starvation i dlaczego Promise.then zawsze wyprzedza setTimeout(0) - opisałem w osobnym poście o event loop.
#Cztery statyczne metody, gdy masz wiele Promise’ów naraz
Pojedyncza obietnica plus .then to fundament. Ale często masz N obietnic i pytanie brzmi: “co znaczy ich łączna gotowość?”. Standard daje na to cztery odpowiedzi - i każda jest innym kompromisem.
Promise.all - wszystkie albo nic
const [users, posts, tags] = await Promise.all([
fetch("/users").then((r) => r.json()),
fetch("/posts").then((r) => r.json()),
fetch("/tags").then((r) => r.json()),
]);Czeka, aż wszystkie się spełnią, i zwraca tablicę wartości w tej samej kolejności. Jeśli którakolwiek zostanie odrzucona - całość natychmiast leci w rejected z tym powodem (fail-fast). Pozostałych Promise’ów to nie zatrzymuje, one nadal się dokonują - ale wynik nas już nie interesuje.
Dobre, gdy potrzebujesz wszystkich danych żeby cokolwiek wyświetlić.
Promise.allSettled - chcę wynik każdego z osobna
const results = await Promise.allSettled(promises);
// [
// { status: 'fulfilled', value: ... },
// { status: 'rejected', reason: ... },
// ...
// ]Czeka aż wszystkie się ustalą (fulfilled albo rejected), nigdy się sama nie odrzuci. Każdy element to obiekt z status i odpowiednio value/reason.
Idealne na “spróbuj 5 endpointów, narysuj te które się udały, zignoruj te które padły”.
Promise.race - kto pierwszy, ten lepszy
const data = await Promise.race([fetch("/fast"), fetch("/slow")]);Zwraca pierwszą obietnicę, która się ustali - obojętnie czy fulfilled czy rejected. Jeśli ten “pierwszy” był rejected, race też jest rejected.
Klasyczny pattern: timeout dla requesta przez Promise.race([fetch(...), wait(5000).then(() => Promise.reject(new Error("timeout")))]). Sam request leci dalej (Promise nie da się zatrzymać - patrz niżej), ale efektywnie ignorujesz odpowiedź, jeśli przyjdzie po terminie.
Promise.any - byle jeden się udał
const data = await Promise.any([
fetch("/region-eu"),
fetch("/region-us"),
fetch("/region-asia"),
]);Zwraca pierwszy fulfilled. Rejecty pomija. Jeśli wszystkie się odrzucą - leci AggregateError z listą wszystkich powodów.
Dobre na “trzy mirror serwery, wystarczy że jeden odpowie”.
#async/await - ten sam mechanizm w ładniejszej składni
async/await z ES2017 nie wprowadza nowego mechanizmu - to tylko inna składnia tego samego, dająca kod, który czyta się synchronicznie, ale działa asynchronicznie.
// .then chain
function loadDashboard(userId) {
return fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((user) => fetch(`/api/posts?author=${user.id}`))
.then((res) => res.json());
}
// async/await - to samo
async function loadDashboard(userId) {
const userRes = await fetch(`/api/users/${userId}`);
const user = await userRes.json();
const postsRes = await fetch(`/api/posts?author=${user.id}`);
return postsRes.json();
}Dwie reguły, które warto trzymać w głowie:
- Funkcja oznaczona
asynczawsze zwraca Promise, niezależnie co napiszesz wreturn.return 42w async function =Promise.resolve(42).throw errw async function =Promise.reject(err). Ten “narzucony” Promise to powód, dla któregoawait someAsync()w funkcji bezasyncjest błędem składni - składnia rozwiązuje Promise, więc musi żyć w kontekście, który Promise rozumie. await“zawiesza” funkcję na czas Promise’a, ale nie blokuje wątku. Pod spodem to nadal callback - JS zapisuje miejsce, w którym był, oddaje wątek event loopowi i wraca tu, gdy Promise się ustali. Wszystko, co poawait, to w praktyce kontynuacja zarejestrowana jako microtask.
I największa zaleta: try/catch znowu działa.
async function loadDashboard(userId) {
try {
const user = await (await fetch(`/api/users/${userId}`)).json();
const posts = await (await fetch(`/api/posts?author=${user.id}`)).json();
return posts;
} catch (err) {
console.error("coś padło:", err);
throw err;
}
}Bo skoro await rozwija rejected Promise w wyrzucony wyjątek, try/catch go widzi w naturalny sposób - tak jak każdy synchroniczny throw.
#Czego Promise nie potrafi
Trzy rzeczy, które warto wiedzieć, bo łatwo się o nie potknąć.
Nie da się go anulować. Skoro stan jest jednorazowym przejściem pending → fulfilled/rejected, nie ma operacji “wycofaj”. Jeśli w trakcie requesta użytkownik zmieni zdanie, Promise nic o tym nie wie - request leci dalej, a callback .then i tak się odpali. Półśrodek to AbortController, który anuluje samą operację (w przypadku fetch - przerywa request po stronie przeglądarki, a Promise dostaje rejected z AbortError). Ale to anulowanie operacji, nie Promise’a.
Executor jest synchroniczny. To, co napiszesz w new Promise((resolve, reject) => { ... }), wykonuje się natychmiast. Dopiero resolve/reject może być wywołane później. Jeśli przez pomyłkę zamkniesz tam ciężki synchroniczny kawałek obliczeń - blokujesz wątek tak samo, jakby był poza Promise.
Niezłapane rejecty są problemem. Promise, który nigdy nie dostał .catch ani await w try/catch, generuje “unhandled rejection” - w przeglądarce trafia do window.onunhandledrejection, w Node.js do process.on('unhandledRejection'). Domyślne zachowanie się różni (Node przez długi czas ostrzegał i działał dalej, od jakiegoś czasu domyślnie crashuje proces - polityka jest konfigurowalna flagą). Krótko mówiąc: każdy łańcuch Promise potrzebuje “podłogi” w postaci .catch albo try/catch wokół await, inaczej błąd po cichu znika z aplikacji, a być może zabija proces.
#Puenta
Sprowadza się to do trzech rzeczy:
- Promise to obiekt-pośrednik dla wartości, której jeszcze nie ma. Daje JS-owi konkretny obiekt reprezentujący trwającą operację - czego callbacki same z siebie nigdy nie dawały.
- Stan przechodzi raz: pending → fulfilled/rejected. Cała kompozycja (
.then,.catch,await,Promise.all) opiera się na tym jednym, prostym mechanizmie. - Callbacki Promise lecą przez microtask queue, która biegnie przed task queue. Stąd “magiczna” kolejność, która zaskakuje w pytaniach rekrutacyjnych - i stąd większość pułapek z
setTimeout(0)w testach.
Większość tego, co API daje wokół Promise - Promise.all, Promise.race, async/await, AbortController - siedzi nad tymi trzema stanami i kolejką microtasków. Dorzuca wygodę, ale mechaniki nie zmienia.
Komentarze
Wczytywanie komentarzy…