Alle Beiträge

Zwei Megabyte für vier Zeilen

In Nuxt Content v3 zog eine Liste rund 2 MB in den Browser: sqlite3.wasm plus DB-Abzug. Ein top-level await machte die Komponente async, sodass Suspense die ganze Seite blockierte. Der Fix verlagert queryCollection per Nitro-Route auf den Server und holt die Daten mit useFetch und lazy.

Zwei Megabyte für vier Zeilen

Eine unscheinbare Liste im Kundenbereich, eine Handvoll redaktionell gepflegter Einträge. Nur ein paar Zeilen Text, ein Datum und eine Überschrift. Eigentlich halb so wild.

Trotzdem sah jeder, der im Kundenbereich auf die Seite mit der Liste wechselte, für einen kurzen Moment eine leere Seite, dann erschien alles auf einmal. Eine Geschichte darüber, wie man zwei Megabyte für eine kleine Liste durchs Internet schiebt.

Die leere Seite

Die erste Anlaufstelle für mich, wenn etwas langsam ist: der Netzwerk-Tab. Dort fand ich etwas, das so gar nicht zu vier Zeilen Text passte. Der Browser lud eine WebAssembly-Datei von rund 840 Kilobyte, sqlite3.wasm, und dazu einen komprimierten Datenblock von gut einem Megabyte. Zusammen knapp zwei Megabyte. Was passiert hier?

Ein Blick in das eingesetzte Content-Framework Nuxt Content erklärte mir die zwei Megabyte. Seit Version 3 liegen die Inhalte in einer eingebetteten SQLite-Datenbank. Auf dem Server ist das schnell, weil eine Anfrage gegen die Datenbank das Parsen von Markdown bei jeder Anfrage spart.

Diese Datenbank muss aber nicht auf dem Server bleiben. Fragt man Content im Browser ab, lädt Nuxt den Datenbank-Abzug herunter und baut daraus per WebAssembly eine SQLite-Datenbank im Browser. Danach laufen alle Abfragen lokal. Für eine Anwendung, die offline funktionieren muss, ist das ein guter Trade-Off.

Übersehen ließ sich das leicht, denn der erste Aufruf kommt vom Server als fertiges HTML, ganz ohne WebAssembly. Erst wer danach innerhalb der laufenden Anwendung navigiert, etwa über einen Link, löst die Query im Browser selbst aus. Genau das passiert beim Wechsel auf die Seite mit der Liste.

Die Engine allein wiegt 840 Kilobyte, und die fallen immer an, vor der ersten Antwort, egal ob die Datenbank vier Einträge hält oder viertausend. Nur der Datenblock darüber wächst mit dem Inhalt. Doch all das hätte die Seite nur verlangsamt, nicht ihr Laden blockiert. Es musste noch einen zweiten Fehler geben.

Die blockierende Komponente: top-level await und Suspense

Also habe ich mir die Listen-Komponente genauer angeschaut. Auf das Wesentliche reduziert, stand dort:

<script setup lang="ts">
const { data: entries } = await useAsyncData(
  'changelog',
  () => queryCollection('changelog').all(),
)
</script>

Das Problem steckt im await in der ersten Zeile, und um es zu sehen, muss man wissen, was Vue damit macht.

Ein top-level await im <script setup> macht die Komponente asynchron. Vue verlangt für solche Komponenten einen Suspense-Block weiter oben im Baum, und Nuxt zieht diesen Block um die gesamte Seite. Suspense funktioniert nach dem Prinzip alles oder nichts und zeigt seinen Inhalt erst, wenn jede asynchrone Komponente darin fertig ist. Bis dahin bleibt die Seite leer. Bewusst eingesetzt ist das nützlich, etwa um auf wichtige Daten zu warten, bevor man eine halbfertige Seite zeigt.

Hier aber hing an dem einen await der Download von zwei Megabyte und der Aufbau einer SQLite-Datenbank im Browser. Solange das lief, war die Komponente nicht fertig, also hielt Suspense die komplette Seite zurück. Erst als alles da war, erschien sie auf einen Schlag.

Der Fix: queryCollection auf den Server, useFetch mit lazy

Die erste Maßnahme war, die zwei Megabyte loszuwerden. Nuxt läuft auf der Server-Engine »Nitro«, über die sich auch eigene API-Endpunkte bereitstellen lassen, und queryCollection funktioniert auch serverseitig. Also verlagerte ich das Abfragen der Datenbank auf den Server. Eine kleine Route holt die letzten Einträge und gibt sie als JSON zurück:

export default defineEventHandler(async (event) => {
  const entries = await queryCollection(event, 'changelog')
    .order('date', 'DESC')
    .limit(4)
    .all()

  return entries.map((entry) => ({
    title: entry.title,
    date: entry.date,
    summary: entry.description,
  }))
})

Die Engine bleibt auf dem Server, im Browser landen nur vier Datensätze als JSON, beschränkt auf die Felder, die die Liste wirklich braucht. Das WebAssembly wird nicht mehr geladen, weil im Browser keine Content-Abfrage mehr läuft.

Jetzt muss das blockierende await noch weg. Die Komponente holt die Daten nun nebenher:

<script setup lang="ts">
const { data: entries } = useFetch('/api/changelog', { lazy: true })
</script>

Das lazy: true macht den Unterschied, denn beim Wechsel auf die Seite wartet sie jetzt nicht mehr auf die Antwort. Sie rendert sofort, die Liste erscheint, sobald die paar Kilobyte da sind. Aus zwei Megabyte und einer blockierten Seite wurde ein Aufruf, den hoffentlich niemand mehr bewusst bemerkt.

Warum überhaupt eine Datenbank im Browser?

Wann Daten geladen werden, ist eine Architekturentscheidung für sich: beim Build (Static Site Generation), pro Anfrage auf dem Server (Server-Side Rendering) oder erst im Browser (Client-Side Rendering). Je später man die Daten lädt, desto dynamischer der Inhalt und desto mehr Last wandert zum Nutzer. Astro und Next.js bleiben meist ganz vorn beim Build und liefern fertiges HTML. Nuxt Content geht bis ans äußerste Client-Ende und lässt queryCollection auch im Browser laufen, wo dieselbe Zeile eine ganze Datenbank-Engine nachzieht. Für eine Anwendung, die dort ständig filtert, lohnt sich der Tausch. Für meine Liste sicherlich eine fragwürdige Entscheidung.

Ein verbreiteter Stolperstein mit sqlite3.wasm

Das eigentliche Problem ist der bequeme Weg. Eine Content-Query direkt in der Komponente liegt am nächsten und steht in jedem Beispiel. Dass sie im Browser eine Datenbank-Engine nachzieht, steht in den Docs nur als Vorteil, ohne den Preis. Wer ihn zahlt, merkt es oft erst beim Deployment. Die WASM-Datei bricht unter strengen Content-Security-Policies, verlangt eigene COOP- und COEP-Header oder sprengt auf Cloudflare das 3-MB-Limit eines Workers.

Würde ich es wieder nehmen?

Der Bug ist erledigt. Würde ich wieder zu Nuxt Content greifen? Ja, mit einer Einschränkung. Der Start ist konkurrenzlos schnell. Man legt Markdown ins Projekt und fragt es ab, ohne separates CMS, das man erst aufsetzen und deployen müsste. Für eine kleine Seite oder einen frühen Stand passt das genau.

Was den Start leicht macht, macht das Ändern mühsam. Inhalt und Code liegen im selben Repository und gehen mit demselben Build hinaus, also kostet selbst eine Textkorrektur ein vollständiges Release. Ein dediziertes CMS würde das trennen. Für ein Magazin, das mehrfach täglich veröffentlicht, ist das ein No-Go. Für eine Anwendung mit wenigen redaktionellen Updates im Monat reicht es völlig.

Newsletter

Neue Beiträge per E-Mail.

Jederzeit abbestellbar.

Newsletter abonnieren