Strona główna / Artykuły

Powszechne błędne wyobrażenia na temat async/await powodujące błędy w produkcji

Wyjaśnia dziewięć subtelnych nieporozumień związanych z async/await – od warunków konkurencyjnych po nierozwiązane błędy – które potajemnie niszczą aplikacje JavaScript w praktyce.

Wydawca: REACTAPP.TOP16 min czytania3694 słów

Źródła: I Used async/await Wrong for Years Without Realizing It

async/await, tylko dlatego, że potrafią go płynnie używać. Wiedza o tym, że funkcja async zawsze zwraca obietnicę, umiejętność umieszczania await przed wywołaniem dotyczącym połączenia z siecią oraz świadomość, że struktura try/catch ułatwia radzenie sobie z awariami asynchronicznymi, mogą wydawać się wystarczające. W porównaniu z kodem opartym na callbackach styl ten wygląda uporządkowanie i jest niemal identyczny pod względem czytelności z zwykłym synchronicznym JavaScriptem: pobiera się użytkownika, ładowa się jego konto, odświeża interfejs i łapie wszystko, co może pójść nie tak w trakcie. Składnia jest tak intuicyjna, że łatwo o tym zapomnieć i nie zastanowić się nad modelem myślowym, który za nią stoi.

Prawdziwym problemem jest to, że ta powierzchowna biegłość obejmuje jedynie strukturę async/await, a nie podstawowe zasady asynchronicznego zarządzania stanem. Powszechnym błędem jest traktowanie await tak, jakby zatrzymywało ono cały program, zakładanie, że wywołanie funkcji asynchronicznej automatycznie łączy jej wynik z osobą, która ją wywołała, oraz przekonanie, że kod napisany w liniowym, od góry do dołu stylu nie może mieć konfliktów z samym sobą. Takie założenia rzadko powodują widoczne problemy w małych demonstracjach, ponieważ jednocześnie wykonuje się tylko jedną operację, odpowiedzi przychodzą szybko, a symulowane dane są przetwarzane w przewidywalnej kolejności. Prawdziwe systemy produkcyjne są o wiele mniej wyrozumiałe.

Błędy, które ostatecznie się pojawiają, nie przypominają typowych błędów asynchronicznych opisanych w podręcznikach. Funkcja zwraca wartość, zanim jakikolwiek efekt uboczny faktycznie się zakończy. Stary żądanie przykrywa stan ustawiony przez nowsze. Blok try/catch nie pozwala na wykrycie odrzucenia. Zadanie zbiorcze uruchamia więcej równoczesnych operacji, niż system jest w stanie realistycznie obsłużyć. Każda obietnica, rozpatrywana osobno, wydaje się w pełni poprawna — jednak cały proces pracy zachowuje się zupełnie inaczej, niż pierwotnie planowano.

Pełne zrozumienie tej kwestii może zajść sporo czasu: async/await nie eliminuje konkurencji w JavaScript. Jego zadaniem jest jedynie zapewnienie pojedynczej funkcji asynchronicznej bardziej czytelnego sposobu na pauzowanie i wznowienie działania. Wszystko inne nadal zależy od tego, które obietnice zostaną zwrócone, które będą oczekiwane, które zostaną cicho odrzucone, które zostaną anulowane lub spróbowane ponownie, a które będą mogły modyfikować wspólny stan w trakcie wykonywania.

Sądziłem, że await wstrzymuje więcej niż jedną funkcję

Częsty pierwszy błąd postrzegania jest dość prosty. Gdy tylko pojawi się await, łatwo jest mentalnie traktować to tak, jakby wstrzymywało ono wszystko wokół siebie. JavaScript nigdy nie blokuje karty przeglądarki ani procesu Node.js, ale nadal łatwo jest założyć, że otaczająca go logika w jakiś sposób poczeka na swoją kolej.

To nie tak to działa. await wstrzymuje jedynie konkretną funkcję asynchroniczną, w której się znajduje. Kontrola wraca do środowiska wykonawczego, dopóki oczekiwana obietnica nie zostanie zrealizowana, a JavaScript nadal obsługuje wszystko inne, co jest gotowe do wykonania. Słuchacze zdarzeń mogą zostać uruchomieni, timerzy mogą wybić, niepowiązane żądania mogą zostać zakończone, a nawet kolejne wywołanie tej samej funkcji może zostać rozpoczęte w międzyczasie.

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

Nic w kodzie zewnętrznym nie czeka tylko dlatego, że loadProfile zawiera instrukcję await. Wywołanie tej funkcji natychmiast ją uruchamia i zwraca obietnicę (promise). Chyba że osoba, która ją wywołała, zdecyduje się na oczekiwanie lub zwrócenie tej obietnicy, wykonywanie kodu po prostu kontynuuje się bez przerwy.

To może brzmieć jak drobna kwestia techniczna, ale jej skutki wykraczają daleko poza nieuporządkowane logi konsoli. Obsługa żądania może wysłać swoją odpowiedź, podczas gdy zapis do logu audytowego jest jeszcze w trakcie. Narzędzie CLI może się zamknąć, gdy zapis pliku wciąż jest w toku. Test może poinformować o sukcesie, zanim zostaną wykonywane żądania potwierdzające zawarte w asynchronicznym callbacku. Funkcja asynchroniczna zachowuje się dokładnie tak, jak została zaprojektowana — prawdziwym problemem jest to, że wywołujący nigdy nie powiązał zakończenia swoich działań z zakończeniem tej asynchronicznej funkcji.

Zatem pytanie, które warto zadać, to nie to, czy funkcja używa wewnętrznie await, ale to, czy obietnica reprezentująca całą operację jest faktycznie połączona z kodem, który musi wiedzieć, gdy zostanie ona zakończona.

Wywoływałem funkcje asynchroniczne, nie decydując się, kto będzie odpowiedzialny za ich zakończenie

Jednym z błędów, które pojawiają się ciągle, jest uruchamianie funkcji asynchronicznej bez oczekiwania na jej zakończenie lub zwrotu wyniku. Czasami jest to prosta nieuwaga, a innym razem praca wydaje się na tyle nieważna, że można ją po prostu uruchomić w tle.

Prawdziwym problemem nie jest koniecznie to, że praca jest wykonywana niezależnie — chodzi o to, że nikt tak naprawdę nie zdecydował, kto będzie odpowiedzialny za jej wynik.

Załóżmy, że zapisywany jest zamówienie, a następnie wysyłana jest powiadomienie. Jeśli to powiadomienie musi bezwzględnie zostać wysłane, zanim cała operacja będzie uważana za zakończoną, osoba wywołująca funkcję musi na nie czekać. Jeśli jest to faktycznie opcjonalne, można pozwolić mu działać samodzielnie, ale odrzucenie tego powiadomienia nadal musi gdzieś trafić. Proste odrzucenie zwróconej obietnicy nie zamienia automatycznie wywołania w niezawodne zadanie w tle – po prostu pozbawia to osobę wywołującą możliwości dowiedzenia się, co stało się później.

Sytuacja ta staje się szczególnie ryzykowna w kodzie backendowym. Funkcja może zwrócić odpowiedź o sukcesie, mimo że jakiś późniejszy asynchroniczny efekt uboczny się nie powiódł. Użytkownik będzie sądził, że operacja zakończyła się pomyślnie, podczas gdy system nie ma trwałego zapisu potwierdzającego, że praca pozostała niedokończona. Jeśli w tym momencie proces zostanie ponownie uruchomiony, ta praca w toku po prostu zniknie.

Pomaga traktować każdą nieoczekiwaną obietnicę jako decyzję architektoniczną, a nie rozwiązanie wpadnięte na ostatnią chwilę. Albo bieżąca operacja jest odpowiedzialna za wynik i musi na niego czekać, albo inna warstwa jest nią zarządzana i potrzebuje odpowiedniego mechanizmu do śledzenia zakończenia, ponownych prób oraz błędów. Nie ma nic złego w celowej pracy w tle – ale nie powinna powstawać tylko dlatego, że ktoś zapomniał napisać await.

Obietnica, której nikt nie obserwuje, z definicji nie jest zadaniem w tle. To po prostu praca bez właściciela.

Traktowanie forEach tak, jakby rozumiał obietnice

Jednym z pierwszych wzorców asynchronicznych, które po cichu podważają standardowy model myślenia, jest łączenie forEach z asynchronicznym callbackiem. Składnia wygląda zupełnie rozsądnie, co utrudnia zrozumienie, dlaczego funkcja nadrzędna kończy swoją pracę, zanim w ogóle zostanie wykonana jakakolwiek rzeczywista operacja.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

Wiadomość o zakończeniu może pojawić się jeszcze przed zapisaniem jakiegokolwiek rekordu użytkownika. Powodem jest to, że forEach wywołuje funkcję zwrotną, ale odrzuca wszystko, co ta zwraca. Funkcja zwrotna asynchroniczna zawsze zwraca obietnicę, więc każde takie wywołanie w tle tworzy obietnicę, do której nikt nie przechowuje referencji. Gdy nie ma już nic, na co można czekać, funkcja zewnętrzna rozwiązuje się natychmiast, niezależnie od tego, co nadal robią jej wewnętrzne wywołania.

To nie jest dokładnie błąd specyficzny dla forEach. Wynika on z założenia, że przekazanie funkcji asynchronicznej do dowolnej metody tablicy automatycznie ulepsza jej specyfikację tak, by rozumiała obietnice. Tak nie jest. Pomocnik do iteracji synchronicznej pozostaje synchroniczny bez względu na to, jaki rodzaj funkcji zostanie do niego przekazany, chyba że ten pomocnik został specjalnie stworzony do koordynacji pracy asynchronicznej.

To samo problem występuje przy użyciu map, filter i reduce. Użycie map z asynchronicznym callbackiem zwraca tablicę pełną oczekujących na realizację obietnic, a nie tablicę wartości końcowych. filter nigdy nie czeka na wynik asynchronicznego predykatu, więc ewaluuje same obiekty obietnic — które zawsze są prawdziwe — zamiast wyników, które ostatecznie przynoszą. Możliwe jest stworzenie asynchronicznej wersji reduce, która będzie działać poprawnie, ale jej zrozumienie jest trudne, ponieważ akumulator to również obietnica, którą należy ostrożnie rozwijać przy każdej iteracji.

Gdy to stanie się jasne, pisanie poprawnego kodu przestaje polegać na zapamiętywaniu, który metodę tablicy jest „dozwolona”, a zaczyna się od wyboru modelu wykonania, który faktycznie jest wymagany przez zadanie. Gdy każdy krok rzeczywiście zależy od ukończenia poprzedniego, pętla for...of z await w środku bezpośrednio wyraża tę intencję. Gdy kroki są niezależne i mogą wystąpić jednocześnie, mapowanie ich na tablicę obietnic i oczekiwanie na nie wszystkie razem za pomocą Promise.all jest często właściwym rozwiązaniem. A gdy konieczne jest ograniczenie równoczesności, ani zwykła pętla, ani nieograniczony Promise.all nie sprawdzą się w tym zadaniu.

Syntakta musi wynikać z potrzeb operacyjnych, a nie odwrotnie. Kuszące jest wybranie pierwszego wzorca, który wydaje się idiomatyczny, i mieć nadzieję, że z niego wyniknie pożądane zachowanie, ale taka kolejność działań jest błędna.

Założenie, że Promise.all przyspiesza działanie bez ryzyka

Gdy odkryjemy, że forEach nigdy nie czeka, naturalnym następnym krokiem jest użycie Promise.all, który zazwyczaj jest odpowiednim narzędziem: przekształcamy elementy na operacje asynchroniczne, przekazujemy powstałe obietnice Promise.all i czekamy, aż cała grupa zakończy się jednocześnie.

Ten model sprawdza się, gdy poszczególne operacje nie są od siebie zależne, rozmiar partii pozostaje rozsądny i pojedyncza awaria ma unieważnić cały wynik. Problemy pojawiają się, gdy jest on używany poza tymi granicami.

Promise.all sam w sobie nic nie ogranicza. Większość podstawowych operacji rozpoczyna się w momencie utworzenia obietnic, więc przetwarzanie kilku tysięcy rekordów może wywołać niemal jednocześnie kilka tysięcy zapytań do bazy danych lub żądań wysyłanych na zewnątrz. Taki kod może wydawać się poprawny przy małych lokalnych zbiorach danych, ale potem może sparaliżować zasoby połączeń, przekroczyć limity liczby prób lub wyczerpać pamięć, gdy musi obsłużyć dane na skalie produkcyjnej.

Jego obsługa błędów może również być zaskakująca. Gdy jedna z obietnic w grupie zostanie odrzucona, pozostałe nie są automatycznie zatrzymywane. Kontynuują działanie, co oznacza, że nadal mogą zapisywać rekordy, wysyłać wiadomości lub modyfikować stan zewnętrzny, nawet po tym, jak kod wywołujący już wszedł do bloku catch. Stwierdzenie „zestaw operacji się nie udał” jest technicznie dokładne, ale nie oznacza, że nie wystąpiły żadne skutki uboczne.

Ponowna próba przetworzenia całej partii pozwala wtedy przepracować to, co już udało się zrobić przy pierwszej próbie. W tym przypadku problemem nie są już mechanizmy obietnic — chodzi o idempotencję oraz radzenie sobie z częściowym ukończeniem zadania.

Zanim sięgnie się po Promise.all, pomocne jest postawienie innych pytań. Ile operacji można bezpiecznie wykonać jednocześnie? Czy są one rzeczywiście od siebie niezależne? Co powinien oznaczać jeden błąd dla reszty partii? Czy istnieje sposób na zatrzymanie pracy, która już się rozpoczęła? Jeśli dojdzie do ponownej próby, czy elementy, które już zostały zakończone, mogą zostać skopiowane? Czy wywołujący naprawdę potrzebuje każdego wyniku, czy akceptowalny byłby uczciwy częściowy sukces?

Czasami Promise.all nadal jest właściwą odpowiedzią. Czasami lepiej sprawdza się Promise.allSettled. Innym razem sytuacja wymaga pętli sekwencyjnej, ogranicznika współbieżności, kolejki lub transakcji obejmującej całą operację. To, które rozwiązanie jest właściwe, zależy od gwarancji, których musi dostarczyć operacja, a nie od tego, jak szybki wydaje się kod na pierwszy rzut oka.

Założenie, że kod czytany od góry do dołu nie może wpadać w sytuacje współbieżności

Pośrednio najdroższe nieporozumienie polega na przekonaniu, że await chroni kod przed sytuacjami współbieżności. W obrębie jednej funkcji instrukcje po await są wykonywane kolejno, co sprawia wrażenie ciągłej sekwencji. Jednak przy kilku jednoczesnych wywołaniach tej samej funkcji kilka jej wykonania może łatwo się nakładać.

Wyobraź sobie dwa żądania, z których każde odczytuje aktualny sald, oblicza nowe saldo i zapisuje je. Oba żądania czekają na odczyt, a następnie na zapis. Każda linia w ramach każdego wywołania jest wykonywana w oczekiwanej kolejności. Sytuacja rywalizacji i tak występuje, ponieważ oba żądania mogą odczytać to samo początkowe saldo, zanim któreś z nich zapisze swoją aktualizację.

Taki sam typ błędu pojawia się w interfejsie użytkownika. Najpierw zostaje wysłane zapytanie dotyczące starszych danych, a potem drugie zapytanie dotyczące nowszych danych. Nowsze żądanie zdarza się rozwiązać pierwsze i poprawnie zaktualizować interfejs. Starsze żądanie kończy się później i nadpisuje ten poprawny stan starym wynikiem. Każde wywołanie czekało na swoje dane dokładnie tak, jak zaplanowano – brakuje jednak reguły określającej, które wywołanie nadal ma uprawnienia do aktualizacji interfejsu.

To należy mieć na uwadze przy każdym użyciu await pomiędzy odczytem stanu a podjęciem działań na jego podstawie. Ta pauza nie jest bezpieczną przerwą w czasie – to okno, w którym mogą być wykonywane inne operacje, które mogą unieważnić założenia poczynione przez funkcję tuż przed pauzą.

Weryfikacje przeprowadzane na poziomie aplikacji nie są automatycznie odpornicze na to okno czasowe. Sprawdzenie, czy nazwa użytkownika jest wolna, a następnie jej użycie nie zapewnia ochrony przed dwoma żądaniami wykonującymi tę samą weryfikację niemal jednocześnie. Sprawdzenie, czy rekord nadal jest w oczekiwaniu przed jego zatwierdzeniem, nie gwarantuje, że żaden inny proces nie zmienił już jego stanu w tym czasie.

Rzeczywista ochrona zazwyczaj musi pochodzić z niższego poziomu: ograniczenie unikalności na poziomie bazy danych, aktualizacja warunkowa, blokowanie optymistyczne, klucz idempotencji, transakcja lub wyraźne zasady własności wdrożone w interfejsie użytkownika. await zarządza jedynie zakończeniem jednej konkretnej obietnicy. Nie robi nic, aby zablokować wspólny stan ani zagwarantować, że wartość odczytana wcześniej nadal będzie prawdziwa w momencie podjęcia działań na jej podstawie.

Oczekiwanie, że try/catch złapie operację, której nigdy nie czekano

Kolejny powszechny błąd wydaje się całkowicie bezpieczny podczas przeglądu kodu. Wywołanie asynchroniczne znajduje się wewnątrz bloku try/catch, więc wydaje się rozsądne założyć, że każda odrzucenie zostanie tam obsłużone.

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Gdy sendAnalyticsEvent jest funkcją asynchroniczną, która odrzuca żądanie po tym, jak już zwróciła swoją obietnicę, otaczający ją blok catch nigdy nie dostrzega tego błędu. Część synchroniczna wywołania zakończyła się pomyślnie w momencie, gdy zwróciła obietnicę. Odrzucenie następuje później, poza ramką stosu, którą monitoruje blok try.

Blok catch działa tylko wtedy, gdy obietnica jest oczekiwana bezpośrednio w jego obrębie:

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Mówiąc prościej, różnica wydaje się oczywista, ale wcześniejsza wersja wygląda bezpiecznie właśnie dlatego, że wywołanie asynchroniczne znajduje się wizualnie wewnątrz obsługownika błędów. Układ sugeruje relację, której kod w rzeczywistości nigdy nie nawiązuje.

Ta sama luka występuje również w callbackach i obsługownikach zdarzeń. Asynchroniczny callback może odrzucić wynik wewnętrznie, ale jeśli kod go wywołujący nigdy nie czeka ani nie sprawdza obietnicy, którą zwraca, to odrzucenie nie ma dokąd pójść. Otoczenie miejsca wywołania instrukcjami try/catch nie pomaga, ponieważ odrzucenie dotyczy obietnicy, której nic w zakresie dostępu nie monitoruje.

Zasadą jest to, że błędy w kodzie asynchronicznym przenoszą się razem z obietnicami, a nie dzięki wcięciom. Aby złapać odrzucenie, trzeba bezpośrednio poczekać na obietnicę, zwrócić ją do łańcucha, który już ma obsługę błędów, lub dodać wyraźną funkcję zwrotną dla odrzuceń. Jeśli odrzuci się obietnicę, jej ścieżka błędu zostanie wraz z nią odrzucona.

To ma również znaczenie dla bloków catch, które pochłaniają zbyt wiele. Przekształcanie każdego błędu w wartość null tworzy oddzielny problem: użytkownicy nie mogą już odróżnić prawdziwego błędu od legalnie pustego wyniku. Po prostu łapanie błędu nie jest celem – kod musi zachować znaczenie tego błędu dla osoby, która go później wykorzysta.

Pomylenie anulowania z odwróceniem

Gdy zespół po raz pierwszy używa AbortController, łatwo jest założyć, że anulowanie żądania oznacza, iż praca w tle rzeczywiście została zatrzymana. W przypadku żądań wysyłanych z przeglądarki anulowanie faktycznie powstrzymuje klienta przed dalszym czekaniem i często zapobiega niepotrzebnemu dalszemu przetwarzaniu na samym klientzie. Jest to naprawdę przydatne w sytuacjach takich jak wyszukiwanie w czasie rzeczywistym, opuszczanie strony lub odrzucenie danych, które już nie są istotne.

Nie gwarantuje to jednak, że praca już przekazana serwerowi zostanie cofnięta.

Jeśli żądanie zapisu zostanie przerwane po tym, jak serwer je już otrzymał, silnik backend może mimo to dokończyć aktualizację bazy danych lub wykonanie zewnętrznego połączenia. Klient wie jedynie, że przestał oczekiwać odpowiedzi. Ponowne wysyłanie tego żądania bez jakiejkolwiek ochrony idempotencji niesie ryzyko powtórzenia operacji, która już zakończyła się pomyślnie po stronie serwera.

Taka różnica istnieje, ponieważ anulowanie z poziomu klienta i stan serwera znajdują się po przeciwnych stronach granicy sieci. Klient może zrezygnować z oczekiwania na wynik, ale nie ma sposobu, by przebić się przez tę granicę i odwrócić skutki, które już nastąpiły.

Anulowanie należy traktować raczej jako sygnał dotyczący istotności danej operacji niż jako gwarancję jej stanu. Informuje ono resztę aplikacji, że osoba wywołująca nie jest już zainteresowana określonym wynikiem, więc ten wynik nie powinien mieć możliwości nadpisania obecnego stanu. W przypadku operacji odczytu przerwanie żądania stanowi rozsądną optymalizację. W przypadku operacji zapisu nadal potrzebny jest oddzielny mechanizm, aby ustalić, czy operacja została zakończona, nadal jest w trakcie wykonywania, czy można ją ponowić bez powielania jej efektu.

Anulowanie odpowiada na pytanie, czy dany wynik jest nadal pożądany. Nie decyduje ono o tym, czy podstawowa akcja nadal jest spójna.

Zapisywanie składni asynchronicznej przed zdefiniowaniem przepływu pracy

Głównym mitem przewijającym się we wszystkich tych błędach jest traktowanie projektowania asynchronicznego jakby była to wyłącznie kwestia składni – decydowanie, czy użyć await, Promise.all czy funkcji callback asynchronicznej, zanim ustali się, jakie gwarancje faktycznie wymaga dana operacja.

Bardziej przydatne pytania pojawiają się wcześniej. Czy wywołujący musi czekać, aż operacja się zakończy, zanim przejdzie dalej? Czy wiele zadań może bezpiecznie działać jednocześnie? Czy ich względny porządek ma znaczenie? Jaka jest właściwa reakcja, gdy niektóre operacje się udają, a inne nie? Czy bezpieczne jest ponowne wykonanie tej operacji? Kto jest odpowiedzialny za obsługę błędu? Czy przestarzały wynik może nadal zastąpić wspólny stan? A gdy coś się timeoutuje, czy oznacza to porażkę operacji, czy tylko to, że dany wywołujący zrezygnował z czekania?

Gdy te pytania zostaną odpowiedziane, sam kod JavaScript zazwyczaj wpisuje się w strukturę bez większych trudności.

W sekwencjach, gdzie kolejność ma rzeczywiście znaczenie, można użyć pętli, która kolejno oczekuje na każdy krok. Niezależne zadania o znanej, ograniczonej liczbie mogą być wykonywane równocześnie. Większe zbiory danych można przetwarzać przy zachowaniu ograniczeń dotyczących równoczesności. Zadania, które nie muszą blokować wywołującego, można przekazać do trwałej kolejki. Aktualizacje wspólnego stanu mogą być kontrolowane za pomocą wyraźnych sprawdzeń prawa własności. Zapisy do bazy danych mogą atomowo zapewnić zachowanie swoich niezmienników na poziomie warstwy przechowywania. Operacje, w których powstanie duplikatu byłoby kosztowne, mogą wykorzystywać klucze idempotencji, aby ponowne wykonanie nie tworzyło drugiej kopii tego samego efektu.

Najtrudniejsze jest właściwe umieszczenie instrukcji await. Chodzi o ustalenie, co dokładnie oznacza „zakończenie” na każdym etapie, który operacja przechodzi.

Znajomość słowa kluczowego, ale nie umowy

Błędne używanie async/await przez lata ma mniej związek z zapomnieniem, jak działa ta składnia, a bardziej z faktem, że sprawia ono, iż kod asynchroniczny wygląda o wiele bardziej sekwencyjnie, lokalnie i przewidywalnie, niż w rzeczywistości jest.

Widok słowa await skłania do założenia, że wszystko wokół niego również zostało wstrzymane. Łatwo przeoczyć wywołanie funkcji asynchronicznej bez określenia, kto będzie odpowiedzialny za jej ostateczne zakończenie. Wykonywanie synchronicznych metod tablic z asynchronicznymi callbackami traktuje je tak, jakby obie strony się rozumiały, czego w rzeczywistości nie ma. Używanie Promise.all bez zastanowienia się nad obciążeniem lub tym, co dzieje się, gdy tylko niektóre obietnice się powiodą, to naturalny, ale ryzykowny nawyk. Zaufanie do kodu, który jest czytany od góry do dołu, nawet gdy kilka wywołań może rzeczywiście pokrywać się w czasie, ukrywa problemy związane z jednoczesnym wykonywaniem operacji. Oczekiwanie, że try/catch złapie błędy pochodzące z obietnic, których nigdy nie czekano, oraz mylenie anulowanej prośby z dowodem na to, że nic się nie stało na serwerze – oba te przypadki wynikają z tego samego braku świadomości.

Każdy z tych błędów wynika z tego samego problemu: różnicy pomiędzy tym, jak wygląda kod, a tym, co faktycznie obiecuje.

Dobre zrozumienie kodu asynchronicznego oznacza śledzenie obietnic pomiędzy granicami funkcji, a nie tylko czytanie od góry do dołu. Oznacza to sprawdzanie, kto je tworzy, kto na nie czeka, kto jest odpowiedzialny za obsługę odrzuceń oraz który element stanu nadal ma władzę nad wynikiem po zakończeniu wszystkich operacji. Oznacza to również uważne obserwowanie każdego miejsca, gdzie await powoduje pauzę, ponieważ to właśnie wtedy inne operacje mogą zmienić założenia, na których opierała się funkcja.

Kod może nadal wyglądać sekwencyjnie na stronie. Taki wygląd nie oznacza już jednak, że system zachowuje się w ten sposób.

Ta zmiana przekształca asynchroniczny JavaScript z zestawu słów kluczowych w model oparty na koncepcjach własności, synchronizacji czasowej i radzenia sobie z błędami. Wyjaśnia również, dlaczego kod, który przez lata wydawał się poprawny, może nadal zachowywać się niewłaściwie w środowisku produkcyjnym, mimo że przechodzi wszystkie testy.

Nauka czekania na realizację obietnicy to najłatwiejsza część.

Zrozumienie tego, co robi reszta programu podczas tego oczekiwania, wymaga znacznie więcej czasu.