Two Megabytes for Four Lines
In Nuxt Content v3 a list pulled around 2 MB into the browser, sqlite3.wasm plus a database dump. A top-level await made the component async, so Suspense blocked the whole page. The fix moves queryCollection to the server via a Nitro route and fetches the data with useFetch and lazy.
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, Nuxt Content, explained the two megabytes. Since version 3 the content lives in an embedded SQLite database. 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, Nuxt downloads the database dump and builds a SQLite database in the browser from it via WebAssembly. After that all queries run locally. For an application that has to work offline, that’s a good trade-off.
It was easy to miss, because the first request comes from the server as finished HTML, with no WebAssembly at all. Only when you then navigate within the running application, via a link for instance, do you trigger the query in the browser itself. That’s exactly what happens when you switch to the page with the list.
The engine alone weighs 840 kilobytes, and that cost is incurred every time, before the first response, whether the database holds four entries or four thousand. Only the block of data on top grows with the content. But all of that would only have slowed the page down, not blocked its loading. There had to be a second bug.
How top-level await and Suspense blocked the page
So I took a closer look at the list component. Reduced to the essentials, it looked like this.
<script setup lang="ts">
const { data: entries } = await useAsyncData(
'changelog',
() => queryCollection('changelog').all(),
)
</script>
The problem sits in the await on the first line, and to see it you have to know what Vue does with it.
A top-level await in <script setup> makes the component asynchronous. For such components Vue requires a Suspense 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 “Nitro”, through which you can also provide your own API endpoints, and queryCollection works on the server side 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. Astro and Next.js mostly stay right at the front, at build time, and deliver finished HTML. Nuxt Content goes all the way to the client end and lets queryCollection run in the browser too, where the same line drags in a whole database engine. For an application that filters there constantly, it can pay off. For my list, admittedly a questionable choice.
A common stumbling block with sqlite3.wasm
The real problem is the “convenient” access to this feature and the hidden cost of the abstraction. A content query directly in the component is the most obvious approach and shows up in every example. That it drags a database engine into the browser appears in the docs only as an advantage, without the price. Whoever pays it often notices only at deployment time. The WASM file breaks under strict content security policies, demands its own COOP and COEP headers, 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.
Newsletter
New posts by email.
Unsubscribe anytime.