Async/await to pułapka — kiedy Promise.all() uratuje Twoją wydajność
Sekwencyjne await to ukryta pułapka wydajności — każde czeka na poprzednie. Kiedy Promise.all() jest odpowiedzią i kiedy równoległość szkodzi?
Maciej Sala
Founder StriveLab
6 min czytaniaOpublikowano 2 listopada 2025 (Aktualizacja 25 maja 2026)
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, i integracjach z kilkoma . 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.
Problem wydajności: 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.
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.
Diagram
Różnica między sekwencyjnym await a Promise.all()
Benchmark async/await vs Promise.all(): realna różnica
W realnym świecie wynik bywa podobny albo jeszcze bardziej odczuwalny, bo operacje sieciowe mają dodatkowy narzut dotyczący połączenia, TLS, kolejki, czasu serwera i opóźnienia między regionami.
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:
Code
// ❌ Zależne — muszą być sekwencyjneconst user = await fetchUser(userId)const orders = await fetchOrders(user.customerId) // potrzebuje user.customerIdconst payments = await fetchPayments(orders[0].id) // potrzebuje orders
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ść.
Drugim ważnym warunekiem jest, że 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ę.
Code
try { const [a, b, c] = await Promise.all([ fetchA(), // OK fetchB(), // FAIL — rzuca błąd fetchC(), // nadal się wykonuje — wynik jest tylko ignorowany ])} catch (error) { // catch dostaje błąd z pierwszej odrzuconej Promise // nie dostajesz częściowych wyników w prosty sposób // pozostałe operacje dalej wykonują się w tle, chyba że same wspierają anulowanie}
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.
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)
Code
// Fallback pattern — użyj pierwszego działającego źródłaconst data = await Promise.any([ fetchFromPrimaryAPI(), fetchFromBackupAPI(), fetchFromCache(),])
Promise.any() rzuca AggregateError tylko jeśli wszystkie Promise zostaną odrzucone.
Wzorzec 2: batch processing z limitem równoległości
Gdy masz dużo operacji i nie chcesz przeciążyć serwera:
Code
async function processBatch(items, fn, concurrency = 5) { const results = [] for (let i = 0; i < items.length; i += concurrency) { const batch = items.slice(i, i + concurrency) const batchResults = await Promise.all(batch.map(fn)) results.push(...batchResults) } return results}// Użycie: przetwórz 100 elementów, max 5 na razconst users = await processBatch(userIds, fetchUser, 5)
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
Code
async function fetchWithRetry(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn() } catch (error) { if (i === maxRetries - 1) throw error const delay = Math.pow(2, i) * 1000 // 1s, 2s, 4s await new Promise((r) => setTimeout(r, delay)) } }}// Użycieconst data = await fetchWithRetry(async () => { const response = await fetch('https://api.example.com/flaky-endpoint') if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json()})
Ważnym szczegółem, jest żefetch() 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 dla zaawansowanych przypadków
Z batchingiem jest jeden problem. Jeśli jedna operacja w paczce trwa długo to cała paczka czeka na nią, zanim ruszy kolejna. Promise pool utrzymuje stałą liczbę "workerów" pracujących równolegle i gdy tylko jeden skończy zadanie, od razu bierze kolejne, zamiast czekać na resztę paczki. Dla bardzo dużych zbiorów danych, gdzie nawet batching nie wystarczy, to rozwiązanie lepiej wykorzystuje dostępną równoległość:
Code
async function promisePool(items, fn, concurrency) { const results = new Array(items.length) let index = 0 async function worker() { while (index < items.length) { const currentIndex = index++ results[currentIndex] = await fn(items[currentIndex]) } } await Promise.all(Array.from({ length: concurrency }, worker)) return results}// 1000 elementów, max 10 równoległych operacjiconst results = await promisePool(largeArray, processItem, 10)
W realnym projekcie często wygodniej użyć gotowej biblioteki npm, która robi dokładnie to samo, np. p-limit albo p-map. Musisz jednak dobrze rozumieć, co dzieje się pod spodem, zanim sięgniesz po zależność.
Typowe błędy przy async/await i Promise
Błąd 1: sekwencyjny await w pętli
Code
// ❌ Źle — każda iteracja czeka na poprzedniąconst sequentialResults = []for (const id of ids) { const data = await fetchData(id) sequentialResults.push(data)}// ✅ Dobrze — równoległeconst parallelResults = await Promise.all(ids.map((id) => fetchData(id)))
// ❌ Źle — forEach ignoruje Promiseids.forEach(async (id) => { await processItem(id) // Te operacje "uciekają"})console.log('Done') // Wyświetli się przed zakończeniem!// ✅ Dobrzeawait Promise.all(ids.map((id) => processItem(id)))console.log('Done') // Teraz czeka na wszystkie
Błąd 4: nieograniczone Promise.all()
Code
// ❌ Ryzykowne — 2000 requestów naraz to proszenie się o problemyconst allAtOnceResults = await Promise.all(ids.map((id) => fetchData(id)))// ✅ Lepiej — limit równoległościconst limitedResults = await processBatch(ids, fetchData, 10)
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 wydajności Promise przez mierzenie czasu
Nie. Promise.all pomaga wtedy, gdy operacje są niezależne i mogą wystartować równolegle. Jeśli jedna operacja zależy od wyniku drugiej, sekwencyjne await jest poprawne.
Kiedy użyć Promise.allSettled zamiast Promise.all?
Promise.allSettled ma sens, gdy chcesz zebrać wyniki wszystkich operacji nawet wtedy, gdy część z nich zakończy się błędem. Promise.all przerywa całość po pierwszym odrzuceniu.
Czy zbyt duża równoległość może zaszkodzić?
Tak. Równoległe uruchomienie zbyt wielu requestów może przeciążyć API, bazę danych lub limit zewnętrznej usługi. Wtedy lepiej użyć ograniczenia współbieżności.
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, czyli 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(urls.map(fetch)) na tysiącu elementów naraz i 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 tylko wygodniejszy zapis na Promise (tzw. "sugar syntax", czyli cukier składniowy) i 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, ponieważ składnia nie chroni przed źle zbudowanym 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.
O autorze
Maciej Sala
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.
Data fetching , czyli sztuka sensownego pobierania danych, to bodajże najstarszy problem w świecie Reacta. Napisanie podstawowego useEffect wraz z fetch jest proste, ale dopisanie do niego logiki, która poprawnie obsłuży cache, usunie zdublowane zapytania, bezbłędnie zadba o powtórzenia w razie błędów retry i elegancko wybroni się przed nadpisywaniem starych wyników race conditions ... to już grubszy temat.
Maciej Sala
Founder StriveLab
Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej okazuje się, że jakość UI zależy od tego, co dzieje się po drugiej stronie API . Jak backend paginuje dane, jak zwraca błędy, jak trzyma sesję, co robi przy timeoutach i czy potrafi przyjąć większy ruch.
Maciej Sala
Founder StriveLab
To trzecia część mojej małej serii „Backend dla frontendowca” i po fundamentach API oraz tematach real-time, webhooków i uwierzytelniania zostaje warstwa, która decyduje o tym, czy aplikacja wytrzyma prawdziwe użytkowanie: cache , kolejki, pliki, deployment, monitoring i bezpieczeństwo .