Składnia async/await w JavaScript miała nas uratować przed callback hell. Zamiast tego wprowadziła cichszy, bardziej podstępny problem: kod, który wygląda poprawnie, ale zachowuje się nieprzewidywalnie. Widzisz await wewnątrz funkcji i zakładasz, że wszystko grzecznie zatrzymuje się linijka po linijce. Często tak nie jest. Pętle wyprzedzają wykonanie. Całe serie zadań padają z powodu pojedynczego nieudanego żądania. Pliki wejściowe bez powodu zarastają brzydkimi wrapperami async. Jeśli napotkałeś któryś z tych problemów, te trzy wzorce pomogą Ci je uporządkować.
Przestań używać await wewnątrz forEach
Oto powszechny błąd, który na pierwszy rzut oka wydaje się nieszkodliwy:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
Uruchom to, a 'All done!' zostanie wypisane, zanim jakakolwiek odpowiedź wróci. Dlaczego? forEach natychmiast wykonuje callback dla każdego elementu. Nie czeka na obietnicę (promise) wewnątrz każdej iteracji. Słowo kluczowe async zmienia każdy callback w obietnicę, którą forEach bezzwłocznie ignoruje. Twoja pętla kończy się w mikrosekundach, podczas gdy żądania sieciowe działają we własnym tempie. Jeśli potrzebujesz, aby błędy były obsługiwane w odpowiedniej kolejności lub chcesz zagwarantować, że jedno żądanie zakończy się przed rozpoczęciem kolejnego, ten wzorzec po cichu łamie oba te założenia.
Zastąp go pętlą for...of:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
Teraz pętla rzeczywiście zatrzymuje się przy każdym await. Drugie żądanie czeka na pierwsze. 'All done!' zostanie wypisane dopiero po zakończeniu wszystkich operacji.
Używaj for...of, gdy kolejność ma znaczenie — np. podczas przesyłania plików jeden po drugim, aby przestrzegać limitów (rate limits), zapisywania wierszy w bazie danych w określonej kolejności lub łańcuchowania wywołań API, gdzie kolejne żądanie potrzebuje danych z poprzedniej odpowiedzi. Jeśli faktycznie chcesz równoległego wykonania, nie baw się w forEach. Sięgnij po Promise.all w sposób jawny, aby Twój zamiar był widoczny dla kolejnego programisty. Nigdy jednak nie mieszaj await z forEach, oczekując zachowania synchronicznego. To się nie wydarzy.
Sięgnij po Promise.allSettled, gdy zero nie może być odpowiedzią
Promise.all jest semantycznie uczciwe. Przekazujesz mu tablicę obietnic, a on zwraca tablicę wyników. Haczyk polega na tym, że w momencie, gdy jakakolwiek pojedyncza obietnica zostanie odrzucona, cała operacja zostaje natychmiast przerwana. Wszystkie inne oczekujące obietnice są pozostawione, by dokończyć pracę we własnym zakresie, ale tracisz dostęp do ich wyników. W środowisku produkcyjnym to zachowanie typu „wszystko albo nic” bywa dotkliwe.
Wyobraź sobie, że Twoja aplikacja pobiera widżety pulpitu nawigacyjnego z czterech niezależnych usług: analityki ruchu, danych o przychodach, opinii użytkowników i stanu zdrowia serwera. API przychodów napotyka krótki timeout. Przy użyciu Promise.all cały Twój pulpit wyrzuca błąd. Trzy poprawne odpowiedzi znikają w próżni. Użytkownik widzi kręcący się spinner, a następnie ekran błędu, ponieważ tylko jedna czwarta danych sprawiała problemy.
Promise.allSettled oferuje bardziej rozsądny kontrakt. Czeka, aż każda pojedyncza obietnica zostanie zakończona, niezależnie od wyniku. Wartość zwrócona to tablica obiektów opisujących każdy wynik:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
Żadna odpowiedź nie zostaje odrzucona. Renderujesz to, co możesz, i izolujesz błąd. Ten wzorzec jest istotny wszędzie tam, gdzie zajmujesz się niezwiązanymi ze sobą operacjami — masową wysyłką powiadomień, wysyłką webhooków zewnętrznych usług czy importem rekordów z wielu strumieni CSV. Nadal potrzebujesz scentralizowanego śledzenia błędów, ale Twoja aplikacja zachowuje stabilność.
Jedna praktyczna uwaga: allSettled zwraca pełny zestaw, więc nadal musisz przejrzeć wyniki i zdecydować, co „częściowy sukces” oznacza dla danej funkcji. Nie traktuj zwróconej tablicy jako zbioru wyłącznie pozytywnych danych. Sprawdź pola statusu, zanim przekażesz cokolwiek do warstwy stanu.
Deklaruj top-level await i pozbądź się wrapperów IIFE
Przez lata, jeśli chciałeś użyć await na poziomie głównym pliku, musiałeś owinąć go w natychmiastowo wywoływaną funkcję async (IIFE):
(async () => {
const config = await loadConfig();
startServer(config);
})();
To działa, ale wprowadza niepotrzebny szum. Top-level await, natywny w ES modules, pozwala pozbyć się tego ceremonialnego kodu powtarzalnego (boilerplate):
const config = await loadConfig();
startServer(config);
Używaj tego w punkcie wejścia swojej aplikacji lub w dedykowanych modułach konfiguracyjnych, gdzie inicjalizacja musi zakończyć się, zanim cokolwiek innego zostanie uruchomione. Ładowanie plików środowiskowych, ustanawianie puli połączeń z bazą danych czy pobieranie zdalnych flag funkcji (feature flags) to naturalne zastosowania. Ponieważ top-level await blokuje wykonanie grafu modułów — inne pliki importujące ten moduł będą czekać na rozwiązanie Twojej obietnicy — otrzymujesz gwarantowany stan. Reszta Twojego kodu może zaimportować db i mieć pewność, że połączenie jest już aktywne.
Istnieją dwa haczyki. Po pierwsze, Twoje środowisko uruchomieniowe (runtime) lub bundler musi obsługiwać moduły ES. W Node.js oznacza to użycie rozszerzeń .mjs lub ustawienie "type": "module" w pliku package.json. Po drugie, ponieważ oczekiwanie na poziomie modułu opóźnia każdego importera, ogranicz zakres oczekiwanych operacji. Ciężkie, sekwencyjne pobieranie danych (fetches) na górze często importowanego pliku z narzędziami (utility file) spowolni proces "cold start" całej aplikacji. Rezerwuj top-level await dla prawdziwych zadań bootstrapowych, od których inne moduły faktycznie zależą.
Co tak naprawdę się zmienia, gdy wdrożysz te wzorce
Pierwszą korzyścią jest przewidywalność. Czytając pętlę for...of, dokładnie wiesz, kiedy blok pod nią zostanie zakończony. Nie ma żadnych "duchów" obietnic (ghost promises) rywalizujących w tle, ani callbacków foreach odłączających się od Twoich obsługiwaczy błędów (error handlers). Przepływ sterowania (control flow) odpowiada kształtowi kodu widocznego na ekranie.
Następna jest odporność (resilience). Promise.allSettled zmusza Cię do myślenia o częściowych awariach, zamiast liczenia na to, że każdy zewnętrzny system będzie działał idealnie. Oprogramowanie produkcyjne nie jest binarne. Niektóre punkty końcowe (endpoints) będą zawodzić. Niektóre odczyty plików napotkają błędy uprawnień. Projektowanie z myślą o rzeczywistości rozproszonych awarii pozwala utrzymać aplikację w pionie, nie zamiatając przy tym istotnych danych pod dywan.
Klarowność spaja to w całość. for...of czyta się jak naturalna progresja. allSettled jasno komunikuje swoje przeznaczenie już w nazwie. Top-level await eliminuje kryptyczne wrappery IIFE, dzięki czemu pliki wejściowe (entry files) zaczynają się od logiki biznesowej, a nie od składniowej akrobatyki. Następny inżynier, który dotknie tego pliku — czy będzie to Ty za sześć miesięcy, czy kolega z zespołu goniący termin — podziękuje Ci.
Praktyczne podsumowanie
Nie traktuj async/await jako uniwersalnej poprawki, którą posypujesz istniejący kod. Przejrzyj swoje obecne projekty pod kątem tych trzech konkretnych antywzorców. Szukaj await wewnątrz bloków forEach i zastępuj je przez for...of lub celowe użycie Promise.all. Przejrzyj każde Promise.all, które komunikuje się z zewnętrznymi usługami, i zadaj sobie pytanie, czy pojedyncza awaria naprawdę powinna kłaść kres całej operacji; jeśli nie, przełącz się na Promise.allSettled i obsłuż mieszane wyniki. Na koniec usuń asynchroniczne IIFE ze swoich punktów wejściowych modułów ES i pozwól, aby top-level await bezpośrednio obsługiwało sekwencję bootstrapową. To małe zmiany mechaniczne, ale razem zmieniają kruche skrypty asynchroniczne w kod, któremu możesz naprawdę zaufać.
