An unremarkable list in the customer area, a handful of editorially maintained entries. Just a few lines of text, a date and a heading. In theory, no big deal.
And yet everyone who switched to the page with the list in the customer area saw a blank page for a brief moment, then everything appeared at once. A story about how you push two megabytes through the internet for a small list.
The blank page
When something is slow, my first port of call is the network tab. There I found something that didn't fit four lines of text at all. The browser was loading a WebAssembly file of around 840 kilobytes, sqlite3.wasm, plus a compressed block of data of a little over a megabyte. Together, just under two megabytes. What's going on here?
A look at the content framework in use, . On the server that's quick, since the database saves re-parsing Markdown on every request.
But this database doesn't have to stay on the server. If you query Content in the browser, block higher up the tree, and Nuxt wraps that block around the whole page. Suspense works on an all-or-nothing principle and shows its content only once every asynchronous component inside it is done. Until then the page stays blank. Used deliberately it's useful, for instance to wait for important data before showing a half-finished page.
Here, though, the download of two megabytes and the building of a SQLite database in the browser hung off that one await. As long as that was running, the component wasn't done, so Suspense held the whole page back. Only once everything was there did it appear all at once.
Moving queryCollection to the server with useFetch and lazy
The first step was to get rid of the two megabytes. Nuxt runs on the server engine too. So I moved querying the database to the server. A small route fetches the latest entries and returns them as JSON.
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,
}))
})
The engine stays on the server, only four records land in the browser as JSON, limited to the fields the list actually needs. The WebAssembly is no longer loaded, because no content query runs in the browser anymore.
Now the blocking await still has to go. The component now fetches the data in the background.
<script setup lang="ts">
const { data: entries } = useFetch('/api/changelog', { lazy: true })
</script>
The lazy: true makes the difference, because when you switch to the page the component no longer waits for the response. It renders straight away, and the list appears as soon as the few kilobytes arrive. Two megabytes and a blocked page turned into a request that hopefully no one notices anymore.
Why a database in the browser at all?
When data is loaded is an architectural decision in its own right. It can happen at build time (static site generation), per request on the server (server-side rendering), or only in the browser (client-side rendering). The later you load the data, the more dynamic the content and the more load shifts to the user. only as an advantage, without the price. Whoever pays it often notices only at deployment time. The WASM file , or blows past the 3 MB limit of a worker on Cloudflare.
Would I use it again?
The bug is fixed. Would I reach for Nuxt Content again? Yes, with one caveat. The start is unmatched in speed. You drop Markdown into the project and query it, with no separate CMS you'd first have to set up and deploy. For a small site or an early stage, it's a perfect fit.
But the easy start comes at a cost. Content and code live in the same repository and ship with the same build, so even a text correction costs a full release. A dedicated CMS would separate the two. For a magazine that publishes several times a day, that's a no-go. For an application with a few editorial updates a month, it's perfectly enough.
SOCIAL SHARE CARD GENERATOR