Blog

Build vs. Runtime in Webprojekten

Der Rendering-Zeitpunkt wird pro Route entschieden. Statische, dynamische und inkrementelle Modelle lassen sich nach Datenfrist, Kosten und Fehlerverhalten kombinieren.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Beim Aufsetzen eines Projekts fällt diese Entscheidung in einer Sekunde: Man wählt ein Framework, übernimmt den Default und rendert ab da alles serverseitig. Bemerkbar macht sich diese Sekunde erst Monate später – wenn der Build zwanzig Minuten braucht oder die Server bei der ersten Lastspitze volllaufen.

Der Rendering-Zeitpunkt einer Seite ist eine Architekturgrenze und keine Einstellung, die man im Build-Setup wegkonfiguriert. Er legt fest, wann eine Seite entsteht, wie frisch ihre Daten sind, was ein Fehler kostet und wie schnell ein Team liefern kann. Beantwortet wird die Frage – rendern wir zur Build-Zeit oder zur Laufzeit? – trotzdem meist implizit. Wer die Grenze bewusst zieht, verkürzt Zyklen, senkt Kosten und nimmt Reibung zwischen Content, Entwicklung und Betrieb heraus.

Die zwei Zeitpunkte

Jede Ausgabe im Web entsteht zu einem von zwei Zeitpunkten. Zur Build-Zeit läuft Arbeit einmal, bevor irgendein Nutzer ankommt: Templates werden zu HTML, Daten werden eingefroren, Assets werden gebündelt und optimiert. Zur Laufzeit läuft Arbeit pro Anfrage oder im Browser, während ein Nutzer wartet: Daten werden geholt, Rechte geprüft, HTML zusammengesetzt, Zustand hydriert.

Der entscheidende Unterschied ist nicht „schnell vs. langsam“, sondern wer wartet und wie oft die Arbeit passiert. Build-Arbeit passiert einmal und wird beliebig oft ausgeliefert – der Nutzer wartet nie darauf. Runtime-Arbeit passiert bei jeder Anfrage erneut – dafür kennt sie den aktuellen Moment, den eingeloggten Nutzer und den letzten Datenstand.

flowchart LR
  subgraph Build["Build-Zeit (einmal)"]
    Content --> Prerender
    Prerender --> StaticHTML["Static HTML + Assets"]
  end
  subgraph Runtime["Laufzeit (pro Request)"]
    Request --> Fetch["Fetch data + auth"]
    Fetch --> Render["Render + hydrate"]
  end
  StaticHTML --> CDN
  CDN --> User
  Render --> User

Zwischen diesen Polen liegt ein Spektrum, kein Entweder-oder: Static Site Generation friert alles zur Build-Zeit ein. Server-Side Rendering baut pro Request neu. Client-Side Rendering schiebt die Arbeit in den Browser. Und Zwischenformen – prerender plus clientseitiges Nachladen, oder statisches HTML mit inkrementeller Revalidierung – mischen beide Zeitpunkte pro Route. Die Kunst besteht darin, die Grenze pro Seite an der richtigen Stelle zu ziehen, statt ein Verfahren für alles auszurufen.

Der Trade-off, konkret

Ich mache den Trade-off an drei Größen fest, weil genau diese drei ein Produkt tragen oder bremsen: Frische der Daten, Kosten pro Anfrage und Time-to-Feedback im Team.

Eine Marketing- oder Doku-Seite ändert sich selten und ist für alle gleich. Sie zur Build-Zeit einzufrieren ist fast immer richtig: Sie liegt danach als statische Datei im CDN, kostet pro Aufruf praktisch nichts, hat keine Server-Latenz und keinen Server, der ausfallen kann.

// Build-time: runs once, per deploy
export async function getStaticProps() {
  const posts = await cms.getPublishedPosts();
  return { props: { posts } };
}

Ein Kontostand, ein personalisiertes Dashboard oder eine Preisliste, die sich stündlich ändert, verträgt kein Einfrieren. Diese Daten gehören an die Laufzeit – entweder pro Request auf dem Server oder als clientseitiger Fetch nach dem ersten Paint.

// Runtime: runs on every request, knows "now" and the user
export async function getServerSideProps(ctx) {
  const user = await auth(ctx.req);
  const balance = await ledger.getBalance(user.id);
  return { props: { balance } };
}

Die eigentliche Frage lautet nicht „SSG oder SSR?“, sondern: Wie lange darf diese Ausgabe alt sein, ohne falsch zu werden? Ich nenne das die Datenfrist. Ist die Frist praktisch unendlich (ein Blogpost von 2019), gehört die Seite in den Build. Ist die Frist null (der aktuelle Kontostand), gehört sie an die Laufzeit. Und dazwischen – die Frist beträgt Minuten oder Stunden – wird es interessant: Dann will ich die Auslieferungskosten eines statischen Assets, aber eine kontrollierte Auffrischung. Genau dafür existieren Caching mit Revalidierung und inkrementelle Regeneration. Sie sind die technische Antwort auf eine endliche Datenfrist, kein Framework-Gimmick.

Was das messbar bewirkt

Der Grund, warum ich diese Grenze so ernst nehme, ist ihre Wirkung auf harte Zahlen.

Eine statisch ausgelieferte Seite antwortet mit der Latenz des nächsten CDN-Knotens – zweistellige Millisekunden, unabhängig von der Last. Dieselbe Seite pro Request serverseitig zu rendern bedeutet Datenbank-Roundtrips, Template-Rendering und einen Server, der mit dem Traffic skalieren muss. Bei einem Lastspitzen-Event ist das der Unterschied zwischen „das CDN liefert eine Datei“ und „unsere Instanzen laufen voll und die Autoscaling-Rechnung explodiert“. Für Seiten ohne echte Datenfrist ist Runtime-Rendering also nicht nur teurer, sondern auch fragiler – man kauft Komplexität, ohne einen Gegenwert zu bekommen.

Umgekehrt kostet zu viel Build-Arbeit Feedback. Wenn jede Änderung an einem Beitrag einen Full-Rebuild von zehntausend Seiten auslöst, wird der Build zum Nadelöhr: Ein Redaktions-Typo braucht 20 Minuten bis zur Sichtbarkeit, und niemand traut sich mehr, kurz vor Feierabend zu deployen. Das ist kein reines Performance-, sondern ein Produktivitätsproblem. Die Konsequenz daraus heißt nicht „mehr Runtime“, sie heißt feinere Grenze: nur die betroffenen Seiten neu erzeugen, statt alles. Kürzere Feedback-Schleifen sind hier der eigentliche Gewinn – sie entscheiden, wie oft ein Team liefert und wie ruhig es das tut.

Ein Fall, der mir in Projekten begegnet ist: Ein Content-lastiges Portal rendert historisch alles serverseitig, weil das mal der Framework-Default war. Unter normaler Last läuft es, aber die Server-Rechnung wächst linear mit dem Traffic, und jede Pressemeldung, die das Portal kurz viral gehen lässt, wird zum Incident. Verschiebt man die zwei Handvoll wirklich statischer Seitentypen – Artikel, Kategorien, Landingpages – in den Build und lässt nur Suche und personalisierte Bereiche an der Laufzeit, verändert sich die Kurve grundlegend. Der Löwenanteil des Traffics wird zur CDN-Datei, die Server tragen nur noch den kleinen dynamischen Rest, und die Lastspitze ist plötzlich ein Nicht-Ereignis. Es wurde keine Zeile Fachlogik umgeschrieben – nur die Grenze zwischen Build und Runtime an die tatsächliche Datenfrist angepasst.

Ich fasse die Entscheidung deshalb gern in einer kleinen Tabelle zusammen, bevor ich ein Tool anfasse:

Seite Datenfrist Zeitpunkt
Landingpage, Docs quasi unendlich Build
Produktkatalog Stunden Build + Revalidierung
Suchergebnis, Feed Sekunden Runtime (Server)
Dashboard, Kontostand null, personalisiert Runtime (Server/Client)

Die Tabelle ist bewusst schlicht. Sie erzwingt genau die eine Frage, die man sonst überspringt: Wie alt darf das hier sein?

Wie ich die Grenze ziehe

Vor der Tool-Frage kläre ich pro Route: Ist die Ausgabe für alle Nutzer gleich oder personalisiert? Wie lange darf sie alt sein? Was passiert, wenn die Datenquelle beim Rendern nicht antwortet? Und lässt sich der Zeitpunkt später ändern, ohne die halbe Anwendung umzubauen? Wenn ich eine Seite so schneide, dass ich ihren Rendering-Zeitpunkt isoliert verschieben kann – von Build zu Runtime, inkrementell oder zurück –, ohne Nachbarrouten anzufassen, dann ist die Grenze brauchbar.

Ein häufig unterschätzter Teil davon ist die Lebensdauer von Zustand. Im Frontend ist die schwierigste Entscheidung selten der Komponentenbaum, sondern die Frage, wo Zustand gehalten wird: in einer Komponente, in der URL, in einem Cache, auf dem Server oder in einem lokalen Modell. Diese Frage ist die Build-vs-Runtime-Frage im Kleinen. Zustand, der in die URL gehört, kann serverseitig gerendert und geteilt werden. Zustand, der nur clientseitig existiert, verlangt Laufzeit im Browser und ist im statischen HTML schlicht nicht vorhanden. Wer diese Zuordnung sauber trifft, bekommt teilbare, indexierbare, testbare Seiten fast geschenkt.

Progressive Enhancement als dieselbe Grenze

Diese Denkweise ist nicht neu, sie ist nur alt geworden. Progressive Enhancement fragt seit jeher: Was liefere ich als robuste Basis, und was reichere ich zur Laufzeit an, wenn der Client es kann? Schon ein Canvas-Element trägt die Haltung in sich – erst prüfen, ob eine Fähigkeit da ist, dann ein Verhalten aktivieren, sonst einen Fallback anbieten.

<canvas id="clock" width="150" height="150">
  <img src="clock.png" alt="Clock fallback" />
</canvas>
const canvas = document.getElementById("clock");
if (canvas.getContext) {
  const ctx = canvas.getContext("2d");
  // enhance at runtime, only when supported
}

Genau dieselbe Reihenfolge – Fähigkeit, Grenze, Verhalten – gilt für serverseitig geliefertes HTML plus clientseitige Hydration. Die statische Basis ist die Build-Ausgabe. Die Anreicherung ist die Laufzeit. Wenn beide Ebenen sauber getrennt sind, funktioniert die Seite auch dann, wenn die Laufzeit-Schicht langsam ist oder ausfällt. Das ist kein Legacy-Wissen, sondern die konzeptionelle Wurzel der ganzen Rendering-Diskussion.

Der Fehlerpfad entscheidet

Eine Rendering-Entscheidung ist erst belastbar, wenn ihr Fehlerpfad beschrieben ist. Was passiert, wenn das CMS beim Build nicht antwortet – bricht der Deploy ab oder geht die Seite mit Lücke live? Was passiert, wenn eine API beim serverseitigen Rendern hängt – blockiert die ganze Antwort, oder rendert die Seite ohne den Teil und lädt ihn clientseitig nach? Diese Fragen wirken trocken, sind aber der Unterschied zwischen Demo und Betrieb.

Build-Rendering verlagert das Ausfallrisiko in die Deploy-Pipeline: Fehler passieren einmal, sichtbar, vor dem Nutzer. Runtime-Rendering verteilt das Risiko auf jede einzelne Anfrage: Fehler passieren live, unter Last, im schlechtesten Moment. Beides ist legitim – aber man sollte wissen, welches Risiko man kauft. Ich prüfe deshalb immer, ob die Entscheidung rückbaubar ist: Kann ich eine Route von Runtime auf Build umstellen, wenn die Last steigt? Kann ich einen Cache gezielt invalidieren, ohne alles neu zu bauen? Wenn die Antwort nein ist, fehlt noch ein Teil der Architektur.

Typische Fehler

Der erste Fehler ist, das Werkzeug mit der Entscheidung zu verwechseln. Ein SSG-Framework ist keine Architektur, SSR ist kein Datenmodell, ein CDN ist keine Frische-Strategie. Diese Werkzeuge können die Grenze zwischen Build und Runtime sauber ausdrücken, aber sie ziehen sie nicht für dich. Wer den Default eines Frameworks übernimmt, hat keine Entscheidung getroffen, sondern eine ausgelassen.

Der zweite Fehler ist, sich am Gutfall zu orientieren. Im Demo-Modus sieht jede Variante schnell aus – ein Datensatz, ein Nutzer, keine Last. Interessant wird es bei zehntausend Seiten, bei Traffic-Spitzen, bei einer langsamen Upstream-API, bei einem Redakteur, der eine Korrektur sofort sehen will. Genau dort zeigt sich, ob die Grenze zwischen Build und Runtime an der richtigen Stelle liegt.

Validierung

Drei Checks reichen mir meistens. Erstens: Für jede Route ist benannt, ob sie zur Build- oder zur Laufzeit entsteht – und warum, ausgedrückt als Datenfrist. Zweitens: Für jede Runtime-Route ist der Fehlerpfad beschrieben, inklusive des Verhaltens bei ausgefallener Datenquelle. Drittens: Die Entscheidung ist pro Route umstellbar, ohne Nachbarrouten anzufassen. Wenn diese drei Punkte stehen, ist die Architektur robust gegen genau die Überraschungen, die solche Projekte sonst spät und teuer einholen.

Weiterführende Quellen

Die Quellen helfen beim Gegenprüfen von Standards und Plattformverhalten. Die eigentliche Aussage bleibt die Architekturentscheidung im konkreten Projekt.

Schluss

Build vs. Runtime ist am Ende eine Frage nach der Datenfrist jeder einzelnen Seite. Ein unveränderlicher Beitrag kann als Datei ins CDN, ein Kontostand braucht aktuelle Daten, und viele Routen liegen mit Revalidierung dazwischen. Wenn diese Entscheidung pro Route verschiebbar bleibt, muss ein neuer Frischebedarf nicht das Rendering der gesamten Anwendung umwerfen.

Wie fandest du diesen Beitrag?

Kommentare