Odkładanie efektów ubocznych w Next.js za pomocą API after()
Dowiedz się, w jaki sposób API after() w Next.js uruchamia analizę danych, logowanie oraz zadania w tle po wysłaniu odpowiedzi, a także jakie są jego gwarancje, pułapki i kompromisy w obsłudze błędów.
Bardzo wiele problemów z wydajnością w aplikacjach Next.js wynika z traktowania każdego zadania jako równie pilnego pod względem czasowym. Zespoły regularnie zmuszają użytkowników do oczekiwania na zapisywanie danych analitycznych, tworzenie ścieżek audytowych, czyszczenie pamięci cache oraz wysyłanie powiadomień przed udzieleniem odpowiedzi. Odwiedzający musi znosić opóźnienie wynoszące 300 milisekund przy wpisywaniu danych do bazy, których rezultat nikt tak naprawdę nie musi widzieć. Sama odpowiedź przychodzi późno, ponieważ kilka niespowiązanych efektów ubocznych musiało zostać wykonywane jeden po drugim w tym samym czasie.
Typowy wzorzec łączy wykonywanie zadań krytycznych z rutynowymi czynnościami konserwacyjnymi. Działania serwera stoją w miejscu, czekając na odpowiedź od usługi logowania. Obsługujące trasy funkcje są wstrzymywane, aby usługa metryk mogła zapisać dany zdarzenie. Każda z tych tlejących czynności wpływa negatywnie na odczuwany czas ładowania, a efektem kumulatywnym jest powolna aplikacja, która odpycha użytkowników.
API after() w Next.js oddziela efekty uboczne od cyklu życia odpowiedzi. Niezbędne do wykonania zadania umieszcza się w funkcji after(), a framework planuje ich wykonanie dopiero po dostarczeniu odpowiedzi. Klient otrzymuje swoją treść natychmiast, podczas gdy logowanie, analiza danych i inne zadania w tle są wykonywane później, całkowicie poza głównym szlakiem przetwarzania.
W dalszej części tego tekstu omówiono mechanizm działania after(), sytuacje, w których jest on przydatny, oraz kwestie operacyjne, które należy wziąć pod uwagę przed użyciem go w środowisku produkcyjnym.
Główne wnioski
after()odkłada efekty uboczne do momentu zakończenia przetwarzania odpowiedzi, dzięki czemu niezbędne do wykonania zadania nie wpływają na główny szlak obsługi żądań przez użytkownika.
waitUntil(), który jest powiązany z Edge Runtime, after() zachowuje się spójnie niezależnie od tego, czy działasz w Node.js, czy w Edge, bez względu na dostawcę hostingu.after() występują po tym, jak klient już otrzymał odpowiedź, konieczne jest wyraźne obsłużenie try-catch, aby je przechwycić i zarejestrować.after().Czym jest API after() i jak ono działa
after() przyjmuje funkcję zwrotną i uruchamia ją po zamknięciu strumienia odpowiedzi. Sekwencja wydarzeń jest następująca: środowisko wykonawcze umieszcza funkcję zwrotną w kolejce, odpowiedź HTTP jest wysyłana do klienta, a dopiero wtedy wykonuje się odroczona operacja. Przeglądarka nie musi czekać na zakończenie tej funkcji zwrotnej.
W ramach jednego żądania Next.js zachowuje kolejność, w której zarejestrowano wywołania after(). Jeśli Twój obsługujący skrypt wywoła after() trzy razy po sobie, te trzy funkcje zwrotne zostaną uruchomione jedna po drugiej, w tej samej kolejności, po zakończeniu przesyłania odpowiedzi. Ta gwarancja kolejności jest ważna, gdy jedno odroczone zadanie zależy od drugiego — na przykład przy zapisywaniu akcji do logu przed unieważnieniem wpisu w pamięci podręcznej, który odzwierciedla tę akcję.
To zupełnie inny model niż po prostu wysłanie obietnicy i zapomnienie o niej. Obietnice typu „wystrzel i zapomnij” mają tendencję do cichego ignorowania nierozwiązanych błędów, co może skutkować niekompletnymi logami lub niesynchronizowanym stanem aplikacji. Funkcja after() sprawia, że silnik wykonawczy wyraźnie odpowiada za planowanie tych zadań, co znacznie ułatwia obserwację i prawidłowe radzenie sobie z błędami w środowisku produkcyjnym.
Różnica pomiędzy wykonywaniem blokującym a nieblokującym staje się oczywista po jej zmierzeniu. Obsługa, która synchronicznie zapisuje informacje przed udzieleniem odpowiedzi, dodaje zazwyczaj od 50 do 150 milisekund na każdą prośbę. Przenosząc tę samą funkcję logowania do metody after(), obsługa może zwrócić wynik w mniej niż 10 milisekund – to właśnie tyle czasu potrzeba na zapisanie danych w bazie danych. Z perspektywy użytkownika odpowiedź wydaje się natychmiastowa, podczas gdy cała obsługa odbywa się niewidocznie w tle.
Zastosowania w praktyce: analiza logów i zadania w tle
Klasycznym przykładem jest śledzenie za pomocą narzędzi analitycznych. Gdy klient kończy proces zakupowy, twoja aplikacja rejestruje tę transakcję i pokazuje ekran potwierdzenia. Platforma analityczna nie musi wiedzieć o tej transakcji w momencie jej dokonania — wystarczy, że dowie się o niej później. Umieszczenie wywołania do śledzenia w funkcji after() może skrócić czas realizacji procesu zakupowego o 100 do 200 milisekund bez utraty żadnych danych.
Rejestracja zdarzeń audytowych działa w ten sam sposób. Zasady zgodności często wymagają, aby każda zmiana stanu była gdzieś zapisana, ale nie ma powodu, dla którego użytkownik musiałby czekać, aż system audytowy potwierdzi tę zmianę. Kluczowa ścieżka obsługuje faktyczną zmianę w bazie danych; funkcja powrotna after() wysyła informację audytową do oddzielnego systemu, zazwyczaj optymalizowanego magazynu logów lub kolejki wiadomości.
Nieważność pamięci cache to kolejny doskonały przykład, szczególnie gdy jej nieważnienie dotyczy kilku zdalnych usług. Aktualizacja treści może oznaczać wyczyszczenie pamięci cache CDN, usunięcie określonych kluczy w Redis oraz wysłanie sygnału do połączonych klientów WebSocket. Żadne z tych działań nie wpływa na to, co użytkownik widzi w odpowiedzi. Aktualizacja zapisuje się w bazie danych, odpowiedź informuje o sukcesie, a funkcja after() zajmuje się późniejszą kaskadową nieważnością.
Wysyłanie e-maili lub powiadomień również dobrze pasuje do obszaru działania after(), pod warunkiem, że aplikacja nie musi synchronicznie pokazywać informacji o nieudanej dostawie. Weźmy przykład sprostowania hasła: aplikacja zapisuje token sprostowania, wysyła komunikat o sukcesie i w tle wysyła e-mail. Jeśli usługa dostawcza e-maili zawiedzie, użytkownik może po prostu spróbować ponownie przez interfejs, zamiast widzieć błąd w pierwotnej prośbie.
Unika to subtelnego, lecz kosztownego sposobu awarii. Jeśli wysyłka e-maila odbywa się bezpośrednio, a usługodawca wygaśnie czas oczekiwania, użytkownikowi pokazany zostanie błąd 500, mimo że token służący do resetu został utworzony pomyślnie. Użytkownik próbuje ponownie, tworząc duplikat tokena, a wtedy system musi usunąć te bezpańskie tokeny lub ryzykować luki w zabezpieczeniach. Obsługa wysyłki e-maila wewnątrz after() całkowicie izoluje tę awarię — odpowiedź nadal jest pomyślna, a oddzielna warstwa monitoringu może niezależnie zauważyć problemy z dostawą.
Przewartościowanie tła i podgrzewanie pamięci cache to podobna sytuacja. Załóżmy, że popularny produkt się wyczerpał: aktualizacja stanów magazynowych powinna wywołać przewartościowanie odpowiednich stron kategorii oraz pamięci cache strony głównej. Sama aktualizacja stanów wraca natychmiast, natomiast funkcja powrotna after() przegląda graf zależności i oznacza przestarzałe wpisy do regeneracji. Odwiedzający przeglądający stronę w tym oknie przewartościowania mogą zobaczyć nieco przestarzałe dane, ale aplikacja nadal działa szybko.
Wdrażanie after() w Server Actions i Route Handlers
Server actions mogą bezpośrednio wywołać after() jako część operacji mutacji. Akcja wykonuje swoją podstawową funkcję, planuje niezbędne efekty uboczne i zwraca kontrolę do klienta. Next.js zarządza cyklem życia w tle, upewniając się, że funkcja powrotna zostanie wywołana przed zamknięciem funkcji serverless.
Obsługi tras mają tę samą strukturę: obsługa wykonywa podstawową pracę, wysyła swoją odpowiedź i umieszcza wszelkie zadania pomocnicze w funkcji after(). Działa to zarówno w obsługach tras App Router, jak i w trasach API Pages Router skonfigurowanych do używania środowiska App Router.
Ważną kwestią jest to, że after() ma dostęp do pełnego kontekstu istniejącego w momencie jego rejestracji. Wszystko, co zostało zapisane w zamknięciu – nagłówki żądania, przetworzone dane ciała, stan autoryzacji – pozostaje dostępne wewnątrz funkcji zwrotnej bez żadnych dodatkowych ustawień.
Middleware również może korzystać z after(), logując metadane żądania bez dalszego spowalniania obsługi w łańcuchu. Middleware pobiera potrzebne nagłówki, planuje wywołanie logowania i przekazuje żądanie dalej. Zapis wpisu do dziennika następuje asynchronicznie, podczas gdy żądanie nadal jest przekazywane do swojego celu.
Stopień niezawodności tej realizacji w dużej mierze zależy od miejsca, w którym ją hostujesz. Platformy takie jak Vercel specjalnie wydłużają czas trwania funkcji, aby callbacki typu after() miały czas na zakończenie pracy. Jeśli samodzielnie hostujesz aplikację w kontenerach serverless, musisz uważnie skonfigurować czas wygaśnięcia – jeśli kontener zostanie wyłączony przed zakończeniem callbacka, cała praca idzie na marne. To poważny problem dla wszystkich elementów, które nie mogą pozwolić sobie na przerwanie działania, takich jak zdarzenia billingowe czy logowanie związane z przestrzeganiem regulacji.
after() vs waitUntil() vs Tradycyjne podejścia
waitUntil(), pochodzący z Edge Runtime, rozwiązuje podobny problem, ale na niższym poziomie: utrzymuje funkcję aktywną, dopóki określone obietnica nie zostanie spełniona, zapobiegając zbyt wczesnemu wyłączeniu środowiska. after() buduje abstrakcję na bazie tego mechanizmu i zachowuje się w ten sam sposób, niezależnie od tego, czy pracujesz z Node.js, czy z Edge.
Projekty, które już używają waitUntil() w kontekście Edge Runtime, mogą stopniowo przejść na after(). Obie funkcje nie są identyczne pod względem struktury: waitUntil() przyjmuje bezpośrednio obietnicę, natomiast after() otacza funkcję callback. Obie służą temu samemu celowi – zapobieganiu przedwczesnemu zakończeniu – ale after() jest łatwiejszy w użyciu, gdy trzeba łączyć kilka zadań w tle, ponieważ oszczędza czas potrzebny na ręczne zarządzanie wieloma obietnicami.
Techniki typu „zapal i zapomnij”, oparte na obietnicach bez konieczności oczekiwania lub wywołaniach setTimeout, nie dają żadnych rzeczywistych gwarancji. Silnik wykonawczy może zostać wyłączony przed zakończeniem realizacji obietnicy, cicho odrzucając wszystkie trwające w tym czasie operacje. Biblioteki do logowania, zbudowane wokół process.nextTick() lub setImmediate(), napotykają ten sam problem po umieszczeniu w środowiskach serverless. Natomiast after() jasno określa intencję i daje platformie rzeczywistą szansę, by ją spełnić.
Kolejki wiadomości wciąż są najbardziej niezawodną opcją do przetwarzania w tle, ale wiążą się z rzeczywistymi kosztami operacyjnymi. Potrzebna jest infrastruktura, dedykowani pracownicy, mechanizmy ponawiania prób oraz panele monitoringu, aby wszystko działało sprawnie. W przypadku prostych efektów ubocznych, takich jak zapisywanie logów czy unieważnianie wpisu w pamięci podręcznej, cała ta konfiguracja jest przesadą. after() znajduje się pomiędzy tymi dwoma skrajnościami: jest bardziej solidny niż prosta, bezobsługowa funkcja, ale znacznie mniej skomplikowany niż budowa systemu opartego na kolejkach.
To kompromis jest najwyraźniej widoczny w sposobie radzenia sobie z błędami. Kolejka wiadomości automatycznie próbuje ponownie wykonać nieudane zadania i może kierować trwałe błędy do kolejki wiadomości typu „dead-letter” w celu późniejszej analizy. Z kolei funkcja powrotna after() jest wykonywana tylko raz na żądanie. Jeśli dojdzie do jej niepowodzenia, ta praca jest utracona, chyba że stworzyłeś własny mechanizm ponownych prób wokół niej. Jest to akceptowalne ryzyko przy operacjach o niskim znaczeniu, ale w przypadku zadań krytycznych, takich jak przetwarzanie płatności lub aktualizacja stanów magazynowych, odpowiednia kolejka pozostaje właściwym narzędziem.
Kwestie związane z produkcją: obsługa błędów i gwarancje wykonywania
Zarządzanie błędami wewnątrz after() jest wyłącznie twoją odpowiedzialnością: umieść logikę w wyraźnych blokach try-catch. Wyjątek rzucony wewnątrz funkcji callback nie dotrze do klienta, ponieważ odpowiedź została już wysłana zanim funkcja callback zostanie wywołana. Platforma zapisze informację o błędzie, ale przywrócenie normalnego funkcjonowania – poprzez ponowne próby, powiadamiania lub alternatywną logikę – jest obowiązkiem aplikacji.
Stopień niezawodności zakończenia callbacku w rzeczywistości zależy w dużej mierze od miejsca, w którym hostujesz aplikację. Na Vercel wykonywanie funkcji jest przedłużane, aby dać callbackom typu after() czas na działanie, do momentu osiągnięcia ustawionego limitu czasowego. Uruchamianie Next.js na AWS Lambda wymaga dokładnej regulacji tego limitu, aby funkcja nie została ponownie wykorzystana przed zakończeniem callbacku. GCP Cloud Run oraz Azure Container Instances stawiają podobne ograniczenia. We wszystkich tych przypadkach wzorzec powtarzających się błędów jest identyczny: funkcja wygaśnia przed zakończeniem odroczonych zadań, a te zadania tracą się na zawsze.
Obserwowalność staje się niezbędna, gdy ten wzorzec trafia do środowiska produkcyjnego. Regularne logi aplikacji obejmują główny cykl życia żądania, ale funkcje zwrotne after() są wykonywane po faktycznym zakończeniu tego cyklu, poza zwykłym kontekstem logowania. Narzędzia do śledzenia rozproszonego, takie jak OpenTelemetry, muszą być skonfigurowane tak, aby wyraźnie rejestrować te funkcje zwrotne jako odrębne obszary śledzenia. Jeśli pominie się ten krok, wszelkie błędy występujące wewnątrz after() staną się praktycznie niewidoczne dla osób monitorujących system.
Testy obciążeniowe ujawniają kolejny aspekt zachowania tego wzorca. Weźmy na przykład obsługę trasy, która przekazuje zadanie trwające 500 ms do metody after(). Przy testowaniu w izolacji taka trasa wydaje się szybka. Jednak przy 100 jednoczesnych żądaniach platforma musi wykonać 100 funkcji zwrotnych mniej więcej w tym samym czasie, co może sprawić, że nie nadąży. Główna ścieżka żądania pozostaje szybka, natomiast zadania odroczone zaczynają się kumulować. Infrastruktura musi być dostosowana nie tylko do ilości przychodzących żądań, ale także do dodatkowego obciążenia generowanego przez prace w tle.
To rozwiązanie koliduje również z API, które wprowadzają ograniczenia szybkości. Wyobraź sobie 1000 żądań trafiających do twojej aplikacji w ciągu minuty, z których każde planuje wywołanie usługi analitycznej wewnątrz after(). Gdy fala odpowiedzi ustanie, dostawca usług analitycznych nagle otrzymuje około 1000 wywołań jeden po drugim, co może skutkować ich ograniczaniem lub odrzucaniem. W takich przypadkach pomocne jest grupowanie logiki powrotnej: zamiast wysyłać żądanie za każdym razem po wystąpieniu zdarzenia, należy gromadzić je w pamięci i wysyłać razem, w większych, rzadszych grupach.
Budowanie partii ma jednak swoje własne problemy z optymalizacją. Bufor przechowujący zgromadzone wydarzenia pozostaje w pamięci aż do wykonania operacji wyczyśczenia, cały czas zużywając zasoby. Nagły wzrost ruchu może wyczerpać tę pamięć, zanim nastąpi zaplanowane wyczyśczenie. Inną opcją jest użycie funkcji zwrotnej after(), która umieszcza wydarzenia na trwałej kolejce zamiast przechowywać je w pamięci, ale wtedy ponownie pojawia się duża część złożoności, której after() miało pomóc uniknąć.
Ostatecznie to, czy after() jest odpowiednią opcją, zależy od tego, jak duża będzie szkoda spowodowana utratą opóźnionej operacji. Takie elementy jak zdarzenia analityczne, wpisy śledzenia audytowego oraz unieważnianie pamięci cache mogą tolerować sporadyczne przerwy w wykonywaniu bez uszkodzenia czegokolwiek kluczowego. Natomiast potwierdzenia płatności, zmiany w zapasach oraz zdarzenia związane z bezpieczeństwem tak nie mogą. Dla tej kategorii zadań kolejka wiadomości lub proste przetwarzanie synchroniczne pozostają bezpieczniejszym rozwiązaniem, nawet jeśli powodują pewną stratę w szybkości reakcji.
Często zadawane pytania
Czy funkcje powrotnicze after() mogą uzyskać dostęp do danych specyficznych dla żądania, takich jak nagłówki czy pliki cookie?
Tak. Funkcja powrotna działa w ramach tego zakresu dostępu, który istniał w momencie wywołania after(), więc wszystkie zmienne, wartości nagłówków lub dane cookie dostępne w tym momencie pozostają dostępne wewnątrz funkcji powrotnej. W praktyce oznacza to, że można odwoływać się do takich elementów jak zalogowany użytkownik, skonwertowana treść żądania lub wydobyte metadane bez konieczności przekazywania ich przez oddzielny kanał.
Co się dzieje, jeśli funkcja powrotna after() wywoła błąd nierozwiązany?
System działania zapisuje go w logach, ale błąd nigdy nie dociera do klienta, ponieważ odpowiedź została już wysłana w momencie wykonywania funkcji powrotnej. To aplikacja musi otoczyć funkcje powrotne after() blokami try-catch i przekazać błędy do narzędzia monitoringu lub mechanizmu ponownych prób. Jeśli tego nie zrobi, błędy po prostu znikają bez śladu.
Czy after() działa zarówno w środowisku Node.js, jak i Edge Runtime?
Tak, API wygładza różnice pomiędzy tymi dwoma środowiskami wykonawczymi. W przypadku Edge Runtime opiera się na tych samych zasadach semantycznych co waitUntil(). W środowisku Node.js wykorzystuje dostępny mechanizm specyficzny dla danej platformy w celu przedłużenia czasu trwania funkcji. Interfejs pozostaje taki sam w obu przypadkach, ale stopień gwarancji prawidłowej eksploatacji nadal zależy od ustawień dostawcy hostingu.
Jak after() porównuje się z używaniem kolejki wiadomości do zadań w tle?
Kolejka wiadomości zapewnia automatyczne próby ponowne, obsługę wiadomości nieudanych oraz niezawodną eksploatację, ale przy tym powoduje dodatkowe obciążenie operacyjne. after() to znacznie lżejsza alternatywa, dobrze nadająca się do zadań ubocznych takich jak logowanie czy unieważnianie cache, gdzie sporadyczne utraty nie stanowią problemu. Gdy praca absolutnie nie może zostać utracona, kolejka wiadomości nadal jest lepszym rozwiązaniem.
Czy kilka funkcji callback połączonych z after() może być wykonywanych jednocześnie, czy też sekwencyjnie?
W ramach jednej prośby o obsługę, funkcje callback są wykonywane po sobie, w kolejności, w której zostały zarejestrowane. Jeśli wywołasz after() trzy razy, silnik wykonuje pierwszą funkcję callback w pełni, zanim rozpocznie drugą, a następnie trzecią. Ta kolejność jest ważna, gdy jedna operacja odroczona zależy od drugiej – na przykład gdy należy zalogować działanie przed unieważnieniem odpowiedniej pozycji w pamięci cache.
Wniosek: Kiedy użyć after() w swoim aplikacji Next.js
after() umożliwia wyodrębnienie zadań nieważnych od ścieżki żądania, na które czeka użytkownik, bez konieczności budowy specjalnej infrastruktury kolejek. Do takich zadań należą śledzenie danych analitycznych, rejestracja działań, unieważnianie pamięci cache oraz wysyłanie powiadomień – dzięki temu użytkownik otrzymuje natychmiastową odpowiedź, podczas gdy aplikacja w tle zajmuje się resztą.
Zaletą jest to, że wykonanie tych zadań nie jest gwarantowane. Platformy serverless szybko usuwają funkcje, a jeśli środowisko przerwie ich wykonywanie w trakcie, te zadania zostaną utracone. Dlatego konfiguracja czasów wygaśnięcia musi uwzględniać specyfikę powrotów, a każdy powrót z after() powinien zawierać własne mechanizmy obsługi błędów. Jeśli sporadyczna utrata tych zadań jest do przyjęcia, prostota after() sprawia, że warto go używać. W przeciwnym razie kolejka wiadomości pozostaje bardziej niezawodną opcją.
Zrozumienie tych wzorców powinno wystarczyć, aby zacząć skutecznie używać funkcji after() w aplikacji Next.js. Jeśli zostanie zastosowana starannie, może znacząco poprawić szybkość działania aplikacji w oczach użytkowników, a różnica ta jest szczególnie istotna na trasach o dużym ruchu, gdzie małe opóźnienia się kumulują i mogą skłonić użytkowników do całkowitego porzucenia sesji.
Literatura pokrewna
- Porównanie kompilatora Go w TypeScript 7 z kompilatorem w aplikacji Next.js — Praktyczne porównanie czasów kompilacji za pomocą tsc w wersjach 6 i 7 na rzeczywistej bazie kodu Next.js, w tym informacje o błędzie niezgodności w CI oraz wskazówki dotyczące aktualizacji.