Startseite / Artikel

Ende der Anfrage „Waterfalls“ in SvelteKit mit parallelen Ladefunktionen

Erfahren Sie, warum sequenzielle awaits die Seiten verlangsamen, und wie SvelteKit-Ladefunktionen, Promise.all, sorgfältige Aufrufe von parent() sowie gestreamte Promises diese Verzögerungen beseitigen.

Verlag: REACTAPP.TOP11 Min. Lesezeit2629 Wörter

Quellen: Killing Request Waterfalls: Server-Side Data Fetching in SvelteKit

Eine Seite kann bei einer schnellen Verbindung und einem schnellen Backend träge wirken, wenn ihre Anfragen nacheinander statt parallel ausgeführt werden. Jeder Hin- und Rückweg wartet darauf, dass der vorherige abgeschlossen ist, wodurch die Zeit bis zum ersten sinnvollen Inhalt aus der Summe aller Anfragen besteht und nicht aus der Dauer der langsamsten. Diese Anleitung erläutert, woher diese Abfolgen von Anfragen stammen, und zeigt, wie man SvelteKit load-Funktionen so strukturiert, dass kritische Daten parallel ankommen, das Layout und das Laden der Seite sich nicht gegenseitig blockieren und langsame, unerlässliche Datenströme erst nach der ersten Darstellung folgen.

Wie eine Anfrageabfolge aussieht

Öffnen Sie das Netzwerk-Panel in den Entwicklerwerkzeugen des Browsers auf einer typischen, vom Client gerenderten React- oder Vue-Dashboard-Seite und laden Sie eine Seite, die mehrere Datensätze benötigt. Die Balken bilden oft eine Treppe: Der erste Anfrage wird abgeschlossen, erst danach beginnt die zweite, und die dritte wartet auf die zweite. Jeder Schritt beinhaltet eine vollständige Netzwerkverbindung hin und zurück.

Der Code hinter dieser „Treppe“ sieht in der Regel unschuldig aus. Ein Komponente wird im Browser geladen, und erst danach stellt sie fest, was sie benötigt. Innerhalb eines Effects oder des Mount-Hooks wird etwas wie const user = await getUser() ausgeführt, anschließend const posts = await getPosts() und dann const comments = await getComments(). Drei Zeilen, drei await-Anfragen – offensichtlich nichts Falsches.

Das Problem liegt darin, was await bedeutet. Es pausiert die Funktion, bis die Promise abgeschlossen ist, sodass der posts-Anfrage erst dann gesendet werden kann, wenn die user-Antwort vollständig eingetroffen ist. Zudem wartet comments wiederum auf posts. Nehmen wir an, jeder Aufruf dauert inklusive DNS-, TLS-, Serververarbeitung und Übertragungszeit etwa 300 ms. Dann hat die Seite für insgesamt etwa diese Dauer nichts Nützliches zu zeigen – und nicht nur so lange wie der längste Vorgang.

Es wird nichts ausgelöst, kein Test fehlschlägt, und jedes Komponente tut genau das, was ihm aufgetragen wurde. Die Benutzer müssen einfach etwa dreimal so lange auf einen Ladebalken schauen wie nötig. Genau diese Unsichtbarkeit macht Wasserfall-Architekturen so verbreitet.

Warum Wasserfall-Architekturen weiterhin auftauchen

Drei gewöhnliche Gewohnheiten sind dafür verantwortlich:

  • Jedes Komponente lädt seine eigenen Daten herunter. In client-seitig rendernden Anwendungen muss ein Elternteil in der Regel zuerst laden, bevor ein Kind geladen werden kann, und das Kind kann seine eigene Anfrage erst starten, nachdem es eine Eigenschaft vom Elternteil erhalten hat. Der Komponentenbaum wird somit zu einer Anfragekette.
  • Aus Gewohnheit Zeile für Zeile warten. Sequenzielle await-Anweisungen sind leicht verständlich, was genau der Grund dafür ist, dass sie verborgen halten, dass dabei Anfragen serialisiert werden, die ursprünglich nicht voneinander abhängig waren.
  • Es gibt keinen einzigen Überblick über die Datenbedürfnisse einer Seite. Wenn der Ladecode auf drei Dateien verteilt ist, fällt es schwer zu erkennen, dass alle drei Anfragen gleichzeitig gestartet werden könnten.

Es hilft nicht, schnelleren Code zu schreiben. Was hilft, ist es, das Abrufen der Daten an einen anderen Ort und zu einer anderen Zeit zu verlegen – bevor irgendein Komponente existiert, an einem koordinierten Punkt. In SvelteKit ist dieser Punkt die load-Funktion. Für einen framework-unabhängigen Überblick über die gleichen Konkurrenzmöglichkeiten siehe den Blog-Vergleich zu Promise.all, Promise.race und sequential awaits.

Wie die load-Funktionen in SvelteKit die Standardverhalten ändern

Jede SvelteKit-Route kann eine load-Funktion exportieren, die vor dem Rendern der zugehörigen Seite-Komponente ausgeführt wird. Die Komponenten suchen danach nicht mehr selbst nach Daten; die Route sammelt alles zuerst ein und übermittelt es der Seite als einzige data-Eigenschaft.

Es gibt zwei Varianten, und die Wahl ist wichtig:

  • Universelle Lastverarbeitung in +page.js wird während der Serverseitigen Darstellung auf dem Server und bei der Clientseitigen Navigation im Browser ausgeführt. Sie eignet sich für Aufrufe an öffentliche APIs sowie zum Zurückgeben von Werten, die nicht serialisierbar sind.
  • Serverlastverarbeitung in +page.server.js findet ausschließlich auf dem Server statt. Der Code wird niemals an den Browser gesendet, sodass dort sicherweise Datenbankklienten, private Umgebungsvariablen und Authentifizierungstoken verwendet werden können.

Auch die Layouts folgen demselben Muster mit +layout.js und +layout.server.js.

In den meisten echten Anwendungen gehört alles, was eine Datenbank, einen internen Dienst oder ein Geheimnis berührt, in +page.server.js. Seine minimale Form sieht wie folgt aus:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  const res = await fetch('/api/user');
  const user = await res.json();
  return {
    user
  };
}

Drei Punkte zu diesem Codeausschnitt sind frühzeitig zu verinnerlichen.

Die Lastverarbeitungsfunktion wird vor der Darstellung der Seite abgeschlossen

Bis +page.svelte mit dem Rendern beginnt, ist user bereits ein gewöhnliches Objekt. Es gibt keinen Mount-Hook, kein Lade-Icon und keinen Moment, in dem der Wert undefined ist.

Nutzen Sie den fetch, den SvelteKit bereitstellt

Der fetch-Parameter ist nicht der globale. SvelteKit bietet eine erweiterte Version, die relative URLs wie /api/user akzeptiert, die Cookies und Header der eingehenden Anfrage bei serverseitigem Rendern weiterleitet und, wenn sie auf einen anderen Pfad innerhalb derselben Anwendung abzielt, direkt diesen Handler aufruft anstelle einer echten HTTP-Anfrage. Das Importieren eines globalen fetch führt dazu, dass all das verloren geht.

Der Rückgabewert wird zur data-Prop der Seite

Was auch immer die Funktion zurückgibt, wird dem Seite-Component als data zur Verfügung gestellt. In einem Svelte 5 Component liest man es mit let { data } = $props(); ein (älterer Svelte 4-Code verwendet export let data;) und gibt anschließend in der Markup-Struktur data.user.name aus.

Das Konzept ist einfach: Zuerst Daten abrufen, danach ausgeben und die bereinigten Werte erhalten anstelle von Promises, die innerhalb des Components verwaltet werden müssen.

Doch das Verschieben des Abrufvorgangs in die load-Funktion beseitigt allein noch keine Kaskadenabfrage. Drei aufeinanderfolgende await-Anweisungen innerhalb von load erzeugen auf dem Server wieder dieselbe Abfolge. Die eigentliche Lösung kommt danach.

Jede unabhängige Anfrage gleichzeitig starten

Die Lösung besteht darin, alle unabhängigen Anfragen sofort zu starten und gemeinsam auf ihre Ergebnisse zu warten:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  // Kick off all three requests immediately - none of them
  // are awaited yet, so none of them block each other.
  const userPromise = fetch('/api/user').then(r => r.json());
  const postsPromise = fetch('/api/posts').then(r => r.json());
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  // NOW wait for all of them to finish, in parallel.
  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise
  ]);
  return { user, posts, comments };
}

Warum das schneller ist

Der entscheidende Faktor ist die Zeitlücke zwischen dem Senden einer Anfrage und dem Warten auf das Ergebnis. Die Aufrufung von fetch(...) sendet die Anfrage sofort; es wird nicht auf ein await gewartet. Das Hinzufügen von .then() beschreibt lediglich, was mit der Antwort zu tun ist, sobald sie schließlich eintrifft. Im obigen Code also:

  • die erste Zeile sendet die Anfrage an /api/user;
  • die zweite Zeile sendet die Anfrage an /api/posts, während die erste noch unterwegs ist;
  • die dritte Zeile sendet die Anfrage an /api/comments, während die anderen beiden noch unterwegs sind;
  • nur Promise.all pausiert tatsächlich und setzt seine Ausführung fort, sobald die langsamste der drei Anfragen abgeschlossen ist.

Die Gesamtzeit verringert sich von der Summe der drei Anfragen auf etwa die Dauer der langsamsten. Die Anfragen, der Server und die Daten bleiben unverändert; nur die Platzierung von await ändert sich. In einem veranschaulichenden Zeitplan, bei dem jede Anfrage etwa 300 ms plus etwas Overhead benötigt, führt dies dazu, dass die Seite von etwa 960 ms auf etwa 360 ms wieder nutzbar wird, wobei die Lücke bei langsamen Netzwerken oder unter Last auf einer API weiter zunimmt.

Nur wenige Änderungen an einer datenintensiven Seite bringen so viel für so wenig. Es muss keine Bibliothek hinzugefügt werden und auch keine Architektur umgestaltet; lediglich einige Zeilen werden neu angeordnet.

Auf was man bei Promise.all achten muss

Promise.all wird abgelehnt, sobald eine der darin enthaltenen Promises abgelehnt wird. Wenn ein fehlgeschlagener comments-Anfragen die gesamte Seite nicht lahmlegen soll, verwenden Sie Promise.allSettled oder weisen Sie jeder Promise einen eigenen .catch()-Handler zu, der einen Ersatzwert zurückgibt. Beachten Sie außerdem, dass r.json() nicht nach r.ok prüft; eine 404- oder 500-Antwort mit einem JSON-Fehlerinhalt wird trotzdem als Daten interpretiert und zurückgegeben, daher prüfen Sie den Status, wenn von seiner Richtigkeit abhängig ist.

Der verborgene „Waterfall“ zwischen Layouts und Seiten

Entwickler, die das Promise.all-Trick kennen, führen oft noch eine zweite Art von „Waterfall“ ein – einen, der sich über Dateien erstreckt: wie das Laden von Daten in einem Layout mit dem des darunterliegenden Pages zusammenwirkt.

Out of the box startet SvelteKit die load-Funktionen von +layout.server.js und +page.server.js gleichzeitig, genauso wie im obigen parallelen Beispiel. Die Lösung hierfür ist parent(), welches der load-Funktion einer Seite Zugriff auf die von den darüber liegenden Layouts zurückgegebenen Daten gibt. Manchmal ist das genau das, was man benötigt – beispielsweise dann, wenn die Abfrage der Seite eine Benutzer-ID erfordert, die nur vom Layout abgerufen werden kann. Wird es jedoch zu früh aufgerufen, serialisiert es alles, was danach kommt:

// src/routes/dashboard/+page.server.js

// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
  const { user } = await parent(); // blocks here until layout's load finishes
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  return { posts };
}

Falls die Anfrage zu den Beiträgen tatsächlich user.id benötigt, ist diese Reihenfolge unvermeidlich und völlig in Ordnung – es handelt sich dabei um eine echte Abhängigkeit. Das Problem tritt auf, wenn die Seite auch Daten benötigt, die nichts mit dem Layout zu tun haben. Das Warten auf parent() in der ersten Zeile hält alle nachfolgenden Anweisungen, einschließlich jener unabhängigen Anfragen, zurück, bis das Layout fertig ist.

Die Lösung besteht darin, die Reihenfolge zu ändern: Zuerst die unabhängigen Aufgaben ausführen und erst an dem Punkt auf parent() warten, an dem sein Wert benötigt wird.

// src/routes/dashboard/+page.server.js

// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  const { user } = await parent(); // runs concurrently with the fetch above
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  const comments = await commentsPromise;
  return { user, posts, comments };
}

Hier wird die Anfrage zu den Kommentaren vor dem Warten der Seite auf das Layout gesendet, sodass sich die beiden überschneiden. Die Anfrage zu den Beiträgen muss weiterhin auf user.id warten, was korrekt ist, und der Wert der Kommentaranfrage hat in der Regel bis zum anschließenden Warten bereits festgestanden.

Die Faustregel: Behandeln Sie await parent() wie jeden anderen await. Setzen Sie es genau dort ein, wo der Wert benötigt wird – niemals reflexartig ganz oben in der Funktion – und starten Sie alle Anfragen, die nicht von den Elterndaten abhängen, vorher.

Daten streamen, die nicht kritisch sind

Promise.all ist nicht immer die richtige Lösung. Wenn eine Anfrage langsam ist und ihre Daten nicht für das Erste benötigt werden, was der Benutzer sieht, verlangsamt das Warten auf diese Anfrage die gesamte Seite bis zu der Geschwindigkeit ihres am wenigsten wichtigen Teils. Auf einer Produktseite sind Name, Preis und Bilder kritisch; der Bewertungsbereich einige Seiten weiter unten hingegen nicht.

Für solche Fälle unterstützt SvelteKit das Streaming. Geben Sie eine Promise von einer load-Anfrage auf dem Server zurück, ohne darauf zu warten, und SvelteKit sendet die bereits gerenderte Seite sofort. Der Wert der Promise wird anschließend dem Browser übermittelt, sobald sie erfüllt ist.

// src/routes/product/[id]/+page.server.js

export async function load({ fetch, params }) {
  // Critical: awaited, blocks the initial render - but it's fast.
  const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
  // Non-critical: NOT awaited. This is handed to the page as a
  // pending Promise, and SvelteKit streams it in once it resolves.
  const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
  return {
    product,          // resolved value
    reviews: reviewsPromise  // still-pending promise
  };
}

Das Produkt wird abgewartet, weil die anfängliche Darstellung es benötigt und der Anfragevorgang schnell ist. Die Anfrage nach Bewertungen wird gestartet, aber nicht abgewartet, wodurch die Seite eine ausstehende Promise unter data.reviews erhält.

Auf der Seite kümmert sich Sveltes {#await}-Block um beide Zustände. Innerhalb von {#await data.reviews} wird ein leichter Platzhalter wie eine Zeile „Bewertungen laden...“ angezeigt, und im {:then reviews}-Zweig wird über die Ergebnisse iteriert. Ein optionaler {:catch error}-Zweig zeigt eine Meldung an, falls die Anfrage fehlschlägt.

Der Benutzer sieht die Produktdetails fast sofort, im Bereich der Bewertungen wird ein kleiner Ladezustand angezeigt, und die tatsächlichen Bewertungen ersetzen diesen sofort, sobald sie eintreffen. Es gibt keinen zusätzlichen Client-Seite-Code zum Abrufen von Daten und auch keinen Mount-Hook.

Hinweise zu Streaming

Haben Sie diese Einschränkungen im Hinterkopf:

  • Das Streaming funktioniert über Funktionen zur Steuerung der Serverlast. Die Promise wird auf dem Server erstellt und von +page.server.js oder +layout.server.js gestreamt. Eine universelle +page.js-Ladefunktion kann ebenfalls eine Promise zurückgeben, wird aber nicht auf dieselbe Weise vom Server gestreamt.
  • Ihr Adapter sowie die Hosting-Plattform müssen gestreamte Antworten unterstützen. Die Node- und Vercel-Adapter tun dies, genauso wie die meisten modernen Plattformen – überprüfen Sie das jedoch bei weniger gängigen Zielsystemen. Proxys, die Antworten puffern, können den Vorteil ebenfalls stillschweigend zunichtemachen.
  • Behandeln Sie Ablehnungen. Eine gestreamte Promise, die ohne {:catch}-Anweisung oder .catch()-Handler abgelehnt wird, kann auf dem Server als unverarbeitete Ablehnung auftreten. Geben Sie nicht-kritischen Promises einen Ersatzweg.
  • Überschriften und Cookies werden einmalig festgelegt, sobald das Streaming beginnt. Alles, was ein Cookie oder einen Statuscode setzen muss, muss vor Beginn der Antwort abgewartet werden.
  • Gestreamter Inhalt hängt von JavaScript im Browser ab. Ohne dieses werden die Nutzer nur den Ladezustand sehen, daher sollte nichts gestreamt werden, was für Suchmaschinen oder Nutzer ohne JavaScript erforderlich ist.
  • Der vollständige Anfragenlebenszyklus

    Durch parallele kritische Abfragen, gezielte Verwendung von parent() sowie Streaming für nicht essentielle Daten durchläuft eine Anfrage an einen datenintensiven Pfad diese Schritte:

    1. Der Browser fordert den Pfad an.
    2. SvelteKit führt die Layout- und Page-load-Funktionen des Pfades auf dem Server aus.
    3. Sämtliche unabhängigen Daten, wie Benutzer, Beiträge und Kommentare, werden parallel abgerufen.
  • Der Server generiert vollständiges HTML mit den bereits eingebetteten Daten, sodass die anfängliche Anzeige keinen Ladezustand aufweist.
  • Der Browser erhält eine einzige, fertige Antwort anstelle mehrerer Netzwerkaufrufe.
  • Svelte aktiviert die Interaktivität in bereits gerenderten Markup-Elementen, während alle gestreamten Versprechen ihre Abschnitte nach und nach bereitstellen, sobald sie erfüllt sind.
  • Vergleichen Sie das mit der clientseitigen Variante von Anfang an. Anstatt dass der Browser nach dem Laden der Seite drei sequenzielle Anfragen sendet, führt der Server diese bereits parallel aus, bevor irgendetwas übertragen wird – und das in der Regel mit einer viel geringeren Latenz zu den Datenquellen als beim Browser des Benutzers.

    Checkliste vor der Veröffentlichung für datenintensive Routen

    Gehen Sie diese Fragen durch, bevor Sie eine Route veröffentlichen, die mehrere Datenblöcke benötigt:

    • Wird die Daten für die anfängliche Darstellung beim Initialisieren einer Komponente heruntergeladen? Bringen Sie sie in eine load-Funktion, meist eine serverseitige, ein.
    • Gibt es mehrere unabhängige await-Anweisungen nacheinander innerhalb von load? Starten Sie zunächst alle Anfragen und warten Sie anschließend mit Promise.all oder Promise.allSettled auf sie.
    • Ist await parent() die erste Zeile in der load-Funktion einer Seite? Verschieben Sie sie dorthin, wo die Elterndaten tatsächlich verwendet werden, und senden Sie vorher unverwandte Anfragen ab.
    • Gibt es Daten, die für die erste Darstellung irrelevant sind? Geben Sie sie als unawaitete Promise zurück und rendern Sie sie mit {#await}, inklusive einer Catch-Abteilung.
  • Stammt die fetch-Anfrage aus den load-Argumenten? Nur diese Version verarbeitet relative URLs korrekt und leitet Cookies während der Serverdarstellung richtig weiter.
  • Zusammenfassung

    „Wasserfälle“ sind kein Zeichen für nachlässigen Code. Sie entstehen einfach, wenn das Abrufen von Daten überall im Komponentenbaum verteilt ist. Die load-Funktionen von SvelteKit sind weniger wichtig, weil sie auf dem Server ausgeführt werden, sondern eher deshalb, weil sie alle Anfragen, die eine Seite benötigt, an einem sichtbaren Ort zusammenfassen – dort kann man entscheiden, welche gemeinsam ausgeführt werden, welche tatsächlich voneinander abhängen und welche später eintreffen können. Die Gewohnheit, dies umzusetzen, ist einfach: Hören Sie auf, reflexartig zu warten, und beginnen Sie stattdessen bewusst zu warten. Wenn dies auf jeder Route angewandt wird, spart es den Nutzern Zeit bei jedem langsamen Verbindungsaufbau und auf jedem überlasteten Backend.

    Weitere Literatur