async/await zrobił dla czytelności JavaScript więcej niż większość nowych składni razem wziętych. Po jego wprowadzeniu, kod wygląda liniowo, łatwiej go prześledzić i trudniej zgubić się w łańcuchu callbacków. Z drugiej strony problem w tym, że bardzo łatwo napisać kod, który wygląda dobrze, a jednocześnie niepotrzebnie serializuje pracę.
Samo await nie jest pułapką, tylko ustawienie kilku await jeden po drugim wtedy, gdy operacje są od siebie niezależne i mogłyby wystartować równolegle.
W praktyce ten błąd pojawia się w dashboardach, loaderach danych, SSR, czyli Server-Side Rendering, oznacza generowanie HTML na serwerze przy każdym żądaniu. i integracjach z kilkoma API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami.. Każdy request jest poprawny, ale całość trwa dłużej, niżeli musi. W tym artykule pokażę, jak to rozpoznać, kiedy Promise.all() faktycznie pomaga, a kiedy lepiej zostać przy sekwencji albo wprowadzić limit równoległości. Jeśli chcesz najpierw zrozumieć, jak JavaScript zarządza asynchronicznością pod spodem, sprawdź artykuł o pętli zdarzeń.
Krótka odpowiedź: Niezależne operacje (user + posts + stats) → Promise.all() (nawet 3x szybciej). Tolerancja błędów → Promise.allSettled(). Wiele operacji na dużym zbiorze → batching z limitem. Sekwencyjne await zostaw tylko gdy wynik jednej operacji jest potrzebny do kolejnej.
Problem: sekwencyjne await
Wyobraź sobie typową funkcję, która pobiera dane z kilku źródeł:
W takim zapisie każda kolejna operacja startuje dopiero wtedy, gdy poprzednia się zakończy i nie jest to wada async/await jako takiego tylko po prostu efekt sekwencyjnego ustawienia await. Pytanie brzmi: ile czasu zajmie wykonanie? Około 900ms plus narzut środowiska, czyli sumujesz czas wszystkich operacji, bo każda czeka na poprzednią.
Ale te zapytania są niezależne, ponieważ nie potrzebujesz wyniku fetchUser() żeby wywołać fetchPosts(), które mogłoby wystartować od razu.
Rozwiązanie: Promise.all()
Ile czasu teraz? 300ms — tyle ile najwolniejsze zapytanie.
Właśnie o to chodzi w Promise.all(), by nie przyspieszać pojedynczych requestów, ale po prostu uruchamiasz wszystkie niezależne operacje jak najwcześniej i czekasz na komplet wyników. W tym przykładzie dostajesz prawie 3x szybciej i to bez zmiany logiki biznesowej albo niepotrzebnego grzebania w backendzie.
Benchmark: realna różnica
Na prostym benchmarku różnica wygląda tak:
W realnym świecie wynik bywa podobny albo jeszcze bardziej odczuwalny, bo operacje sieciowe mają dodatkowy narzut: połączenie, TLS, kolejki, czas serwera i opóźnienia między regionami.
Kiedy używać Promise.all()?
Używaj gdy operacje są niezależne:
Najprostsza heurystyka jest taka: jeśli potrafisz przygotować wszystkie wywołania przed pierwszym await, jest duża szansa, że możesz wykonać je równolegle.
Nie używaj gdy wynik jednej operacji jest potrzebny do drugiej:
Jeśli część operacji jest zależna, a część nie, rozdziel to na etapy. Najpierw pobierz to, od czego zależą kolejne kroki, a dopiero potem odpal równoległość.
Drugi ważny warunek: niezależność to nie wszystko. Jeśli masz setki albo tysiące zadań, Promise.all() bez limitu może skończyć się rate limitingiem, problemami z pamięcią albo przeciążeniem API. Wtedy lepszy będzie batching albo pool, o których za chwilę.
Obsługa błędów: Promise.all vs Promise.allSettled
Promise.all() ma zachowanie fail-fast: pierwszy odrzucony Promise odrzuca całą agregację.
To bardzo dobre zachowanie wtedy, gdy komplet danych jest wymagany. Jeśli dashboard bez jednej sekcji i tak nie ma sensu, fail-fast upraszcza obsługę błędów.
Jeśli chcesz kontynuować mimo błędów, użyj Promise.allSettled():
Promise.allSettled() jest świetne do sytuacji, w których częściowy sukces nadal ma wartość: widgety dashboardu, kilka źródeł danych, logowanie błędów bez blokowania całej strony.
Promise.race() i Promise.any()
Dwa inne przydatne narzędzia:
Promise.race() — pierwszy wynik (sukces lub błąd)
To dobry wzorzec dla timeoutów, ale pamiętaj: Promise.race() nie anuluje przegranego zadania. Jeśli timeout ma faktycznie przerwać fetch, użyj AbortController.
Promise.any() — pierwszy sukces (ignoruje błędy)
Promise.any() rzuca AggregateError tylko jeśli wszystkie Promise zostaną odrzucone.
Wzorce dla złożonych scenariuszy
Wzorzec 1: Częściowa równoległość
Gdy masz miks zależnych i niezależnych operacji:
Wzorzec 2: Batch processing z limitem równoległości
Gdy masz dużo operacji i nie chcesz przeciążyć serwera:
To nadal jest sekwencja, ale na poziomie batchy. Wewnątrz każdej paczki operacje lecą równolegle, dzięki czemu ograniczasz obciążenie bez całkowitej utraty wydajności.
Wzorzec 3: Retry z exponential backoff
Ważny detal: fetch() nie odrzuca Promise przy 404 czy 500, więc jeśli chcesz retry dla błędów HTTP, musisz rzucić wyjątek samodzielnie. Retry ma też największy sens dla operacji idempotentnych, zwłaszcza GET.
Wzorzec 4: Promise pool (zaawansowany)
Dla bardzo dużych zbiorów danych, gdzie nawet batching nie wystarczy:
W realnym projekcie często wygodniej użyć gotowego narzędzia typu p-limit albo p-map, ale dobrze rozumieć, co dzieje się pod spodem.
Typowe błędy
Błąd 1: Sekwencyjny await w pętli
Błąd 2: Await w map bez Promise.all
Błąd 3: forEach z async
Błąd 4: Nieograniczone Promise.all()
Promise.all() nie ma wbudowanego limitu i dla małej liczby operacji to świetne rozwiązanie, choć dla dużej liczby zadań może pogorszyć stabilność zamiast poprawić.
Debugowanie: mierzenie czasu
FAQ
Kiedy używać Promise.all zamiast sekwencyjnych await?
Gdy operacje są od siebie niezależne — wynik jednej nie jest potrzebny do uruchomienia kolejnej. Typowe przypadki: pobieranie danych z wielu endpointów dla jednej strony (user + posty + statystyki), równoległe zapytania do bazy, wiele niezależnych walidacji. Prosta heurystyka: jeśli możesz zapisać wszystkie wywołania przed pierwszym await, prawdopodobnie możesz uruchomić je równolegle. Efekt: czas = najwolniejsza operacja zamiast suma wszystkich.
Czym różni się Promise.allSettled od Promise.all?
Promise.all() działa w trybie fail-fast — pierwsza odrzucona Promise powoduje odrzucenie całej agregacji. Promise.allSettled() zawsze czeka na wszystkie Promise i zwraca tablicę obiektów ze statusem (fulfilled/rejected) i wynikiem lub błędem. Użyj allSettled gdy częściowy sukces ma wartość: widgety dashboardu mogą się ładować niezależnie, błąd jednego nie powinien blokować pozostałych.
Jak poprawnie obsługiwać błędy w Promise.all?
Owiń w try/catch: przy błędzie dostajesz wyjątek z pierwszego odrzuconego Promise. Pozostałe Promise nadal wykonują się w tle — Promise.all nie anuluje ich automatycznie. Jeśli chcesz anulować operacje przy błędzie, użyj AbortController. Jeśli chcesz częściowe wyniki mimo błędów, użyj Promise.allSettled() zamiast Promise.all().
Jaka różnica między Promise.race a Promise.any?
Promise.race() rozwiązuje się pierwszym wynikiem niezależnie od tego, czy to sukces czy błąd. Przydatne do implementacji timeoutów: Promise.race([fetch(...), timeoutPromise]). Promise.any() ignoruje błędy i rozwiązuje się pierwszym sukcesem — rzuca AggregateError tylko gdy wszystkie Promise zostaną odrzucone. Przydatne do pattern fallback: użyj pierwszego działającego źródła danych spośród kilku.
Jak ograniczyć równoległość async operacji?
Batching: podziel tablicę na kawałki i przetwarzaj każdy przez Promise.all() — sekwencja batchów, równoległość wewnątrz batcha. Pool (zaawansowany): utrzymuj stałą liczbę równoległych workerów. Biblioteki jak p-limit lub p-map robią to gotowo. Ważne: Promise.all(1000items.map(fetch)) bez limitu może skutkować rate limitingiem, wyczerpaniem pamięci lub przeciążeniem serwera.
Czy async/await jest wolniejszy od .then()?
Nie — async/await to sugar syntax nad Promise, kompiluje się do identycznego kodu. Problem z wydajnością nie leży w samej składni, lecz w tym, że await blokuje dalsze wykonanie w danej funkcji, co łatwo prowadzi do nieświadomej serializacji niezależnych operacji. Przy .then() te same błędy można popełnić równie łatwo — składnia nie chroni przed złym projektem przepływu asynchronicznego.
Dlaczego forEach z async nie działa poprawnie?
Array.prototype.forEach nie obsługuje asynchronicznych callbacków — wywołuje callback, nie czeka na Promise i idzie dalej. W efekcie await wewnątrz forEach działa w izolowanym kontekście i "ucieka" spod kontroli zewnętrznej funkcji. Kod po forEach wykonuje się zanim operacje wewnątrz się skończą. Zamiast forEach z async: await Promise.all(arr.map(async item => ...)) dla równoległości lub for...of z await dla sekwencji.
Źródła i dokumentacja
Podsumowanie
Zbierając wszystkie informacje, podsumujmy:
| Scenariusz | Rozwiązanie |
|---|---|
| Niezależne operacje | Promise.all() |
| Niezależne, toleruj błędy | Promise.allSettled() |
| Pierwszy sukces | Promise.any() |
| Pierwszy wynik lub timeout | Promise.race() |
| Miks zależnych i niezależnych | Etapy + Promise.all() |
| Wiele operacji + limit | Batching, pool lub limiter |
Najważniejsza zasada nie będzie tutaj brzmiała: "zawsze używaj Promise.all()", ale raczej: uruchamiaj niezależne operacje jak najwcześniej, a czekaj na nie dopiero wtedy, gdy naprawdę potrzebujesz wyniku tych operacji.
async/await sprawia, że sekwencyjny kod pisze się bardzo naturalnie, i generalnie dlatego tak łatwo przeoczyć miejsca, w których aplikacja niepotrzebnie stoi.
Musisz sobie zadać jedno proste pytanie przy każdym await:
"Czy ta operacja naprawdę musi czekać na poprzednią?" i jeśli tak, to rób to.
Chcesz pójść dalej? Sprawdź 10 trików JavaScript dla czytelniejszego kodu albo zobacz, jak debugować JavaScript bez zasypywania kodu console.logami.
