Partial Updates
Davaux supports partial updates — a way to stream slow content into a page after the initial HTML has already been sent to the browser, with no client-side JavaScript required on your part.
The feature is built on the browser's Declarative Partial Updates API, a new platform primitive being standardized in Chrome. Polyfills for browsers without native support are injected automatically.
The problem
A common pattern in SSR frameworks is to await all data before sending anything. If your page has one fast query and one slow query, the user sees nothing until both complete:
export default definePage(async (ctx) => {
const nav = await getNavItems() // 10ms
const feed = await getActivityFeed() // 800ms — holds everything up
return (
<Layout nav={nav}>
<ActivityFeed items={feed} />
</Layout>
)
})
The shell, nav, and page chrome are all ready in 10ms but the browser receives nothing for 800ms.
The solution
ctx.defer(name, promise) lets you mark slow content as a named slot. The framework immediately streams the shell with a placeholder in place of the slow content, then streams the resolved content in a <template> element when the promise resolves — all within a single HTTP response.
export default definePage(async (ctx) => {
const nav = await getNavItems() // awaited normally
const feed = ctx.defer('feed', getActivityFeed() // not awaited
.then((items) => <ActivityFeed items={items} />)
)
return (
<Layout nav={nav}>
{feed}
</Layout>
)
})
The browser receives the shell — with nav and full page chrome — almost instantly. The activity feed fills in as soon as getActivityFeed() resolves, with no visible layout shift because the slot reserves the correct position.
API
ctx.defer(name: string, content: Promise<OmlNode | string>): Promise<OmlNode>
| Parameter | Type | Description |
|---|---|---|
name | string | Unique slot name within the page. Used to match the placeholder with its content. |
content | Promise<OmlNode | string> | A promise that resolves to either a JSX node or an HTML string. |
Returns an OmlNode that renders as a <?marker name="..."> processing instruction — a standard HTML placeholder that the browser (or polyfill) replaces when the matching <template> arrives later in the stream.
Multiple slots
Each call to ctx.defer() registers an independent slot. They resolve and stream in the order they were registered.
export default definePage(async (ctx) => {
const hero = ctx.defer('hero', getHeroContent().then((d) => <Hero data={d} />))
const stats = ctx.defer('stats', getStats().then((d) => <StatsBar data={d} />))
const feed = ctx.defer('feed', getFeed().then((items) => <Feed items={items} />))
return (
<Layout>
{hero}
{stats}
<section class="main">{feed}</section>
</Layout>
)
})
Placeholder content
Use <?start name="..."> and <?end> range markers around placeholder HTML that should be visible while the deferred content loads. Write these as raw HTML strings alongside the marker:
const feed = ctx.defer('feed', getFeed().then((items) => <Feed items={items} />))
// In JSX, emit the start/end range markers around a skeleton:
return (
<Layout>
<div dangerouslySetInnerHTML={{
__html: `<?start name="feed"><div class="skeleton"></div><?end>${feed}`
}} />
</Layout>
)
The skeleton is shown immediately and replaced — without a flash — when the real content arrives. Native browser implementations suppress the skeleton entirely during parse; polyfilled browsers may show it briefly.
How it works
When a page uses ctx.defer(), Davaux switches the response to streaming mode:
- The framework renders the full OML tree. Each deferred slot renders as a
<?marker name="...">processing instruction inline in the HTML. - The HTTP response starts immediately — headers are sent and the shell is flushed to the browser.
- Each deferred promise is awaited in order. As each resolves, a
<template for="name">element is appended to the stream:<template for="feed"><ul><!-- rendered items --></ul></template> - The browser's parser (or the polyfill's
MutationObserver) sees the<template for="feed">, finds the<?marker name="feed">it placed earlier in the DOM, and replaces it with the template's content. - When all deferred promises have resolved, the response ends.
Pages without any ctx.defer() calls are unaffected — they use the standard single-shot response path.
Browser support and polyfills
Declarative Partial Updates is available in Chrome 148+ behind a flag (chrome://flags/#enable-experimental-web-platform-features). Other browser vendors have indicated positive intent.
Davaux ships polyfills for both halves of the API:
template-for-polyfill— handles<?marker>/<template for>out-of-order streaminghtml-setters-polyfill— addsappendHTML(),streamHTML(), and related DOM methods
The polyfills are self-detecting: each checks for native browser support at load time and does nothing if the browser already handles the API natively. You don't need any user-agent detection or conditional loading.
Polyfill scripts are injected into ctx.head.scripts automatically on the first call to ctx.defer() in a request. Pages that don't use deferred slots are never affected.
Limitations
Polyfill streaming is buffered. The template-for-polyfill uses a MutationObserver to detect arriving <template for> elements. This means the placeholder may be visible momentarily before content fills in, unlike native browser implementations where the replacement happens synchronously during parse.
Deferred slots resolve in registration order. If your fastest query is registered last, it waits for the slower ones ahead of it. Register in priority order, or launch all promises before calling ctx.defer():
// Launch all promises first, then register slots in priority order
const heroPromise = getHeroContent()
const feedPromise = getFeed()
const statsPromise = getStats()
const hero = ctx.defer('hero', heroPromise.then((d) => <Hero data={d} />))
const feed = ctx.defer('feed', feedPromise.then((d) => <Feed items={d} />))
const stats = ctx.defer('stats', statsPromise.then((d) => <Stats data={d} />))
All three fetches run in parallel; slots are just written in the order they finish being awaited.
Not compatible with ctx.send(). Deferred slots require Davaux to own the response stream. Using ctx.send() to write a manual response from the same handler will conflict.
Production polyfill delivery. In dev mode, polyfill scripts are served from /_davaux/partial-updates.js. For production builds, copy the polyfill files from node_modules/template-for-polyfill/dist/template-for-polyfill.js and node_modules/html-setters-polyfill/index.min.js into your public/ directory and add them to ctx.head.scripts in your layout — or configure a CDN URL. Built-in production serving is planned.