DEV Community

Cover image for Declarative Partial Updates: Streaming HTML Where It Matters Most
Shidhin
Shidhin

Posted on Edited on Originally published at shidh.in

Declarative Partial Updates: Streaming HTML Where It Matters Most

Since I joined the Dashboard team at Xendit, I've been taking care of frontend platform work like this. And I keep coming back to one question: what does the user see first, and how long do they wait for it? A typical dashboard page has a header, a side navigation, a few summary cards and a big table of recent transactions. The header and the navigation are ready in a few milliseconds. The table depends on a slow query. And because HTML is delivered top to bottom, the fast parts of the page often end up waiting for the slow one.

We usually solve this with JavaScript. Render a skeleton, fetch the data on the client, and swap the content in when it arrives. It works, but it means more JavaScript, more round trips, and more code to maintain for something the server already knew how to render.

Chrome recently shipped a set of platform features under the name Declarative Partial Updates. They let the server stream HTML out of order, and let JavaScript stream HTML into an existing page. In this blog post, we'll explore how these features work, look at a streaming dashboard example, combine both APIs into an SPA-style navigation, and see how to use them today with polyfills.

The problem: HTML is delivered in order

The HTML parser reads a document from top to bottom. If the transactions table sits in the middle of the page, everything after it has to wait until the server finishes rendering that table. The server can flush the header early, but it can't send the footer before the table, because the footer comes later in the document.

To work around this, we usually pick one of these options:

  • Wait for the whole page to be ready, and send it in one go. Simple, but the slowest part decides the speed of the whole page.
  • Send a skeleton and fetch the slow parts with JavaScript. Fast first paint, but the data needs an extra round trip after the JavaScript loads.
  • Use a framework that streams HTML out of order. Frameworks like React do this with hidden containers and small inline scripts that move the content into place. It works well, but it's framework machinery for something HTML can't express on its own.

The interesting part is that, in all three cases, the server already knows the content. The only thing stopping it from sending the content early is the order of the document.

What are Declarative Partial Updates?

Declarative Partial Updates is a project from the Chrome team, incubated in the WICG. The first two pieces of it are now available:

  1. Out-of-order streaming. A <template> element with a new for attribute can patch content into named placeholders, anywhere earlier in the document.
  2. New HTML insertion and streaming methods. A consistent set of JavaScript methods like setHTML(), appendHTML() and streamHTML(), with streaming versions that accept a WritableStream.

The first one needs no JavaScript at all. The second one fills the gap for content that can't come from the initial HTML response. Let's look at each of them.

Out-of-order streaming with <template for>

Out-of-order streaming uses two building blocks:

  • Processing instructions as named placeholders. They look like <?marker name="x">. XML has had processing instructions for a long time, but HTML used to treat them as comments. They are now part of the HTML standard.
  • <template for="x"> as the patch. When the parser reaches the template, it looks up the placeholder with the same name and puts the template's content there.

There are three kinds of placeholders:

  • <?marker name="x"> is an insertion point.
  • <?start name="x"> and <?end> wrap a range of fallback content, which gets replaced.

Example 1: A simple marker

<div>
  <?marker name="greeting">
</div>

<!-- ...rest of the page... -->

<template for="greeting">
  Here is some <em>HTML content</em>!
</template>
Enter fullscreen mode Exit fullscreen mode

After parsing, the <div> contains the content of the template. The template itself doesn't stay in the DOM.

Example 2: Fallback content with start and end

<section id="summary">
  <?start name="summary">
  Loading summary…
  <?end>
</section>

<!-- ...later in the same response... -->

<template for="summary">
  <p>Volume: 1,204 · Success rate: 98.2%</p>
</template>
Enter fullscreen mode Exit fullscreen mode

The user sees Loading summary… until the template arrives. Then the range is replaced with the real content. This is the pattern I'd use for dashboard cards.

Example 3: Appending items one by one

A template can include a new marker with the same name. This lets the next template continue from where the last one stopped:

<ul>
  <?start name="results">
  Loading…
  <?end>
</ul>

<template for="results">
  <li>Result One</li>
  <?marker name="results">
</template>

<template for="results">
  <li>Result Two</li>
  <?marker name="results">
</template>
Enter fullscreen mode Exit fullscreen mode

The final DOM has both list items, followed by one <?marker name="results">, ready for more.

Why processing instructions, and not elements?

This was my first question too. Why not use <slot> or another element as the placeholder? The answer is that processing instructions don't affect the DOM structure. So you can use them in places where an element isn't allowed. For example, inside <head> to update the <title>, or inside a <table> to add rows. An earlier version of the proposal used elements, and the design moved to processing instructions as it evolved.

Example 4: Streaming a dashboard from a Node server

Let's put this together with a small Node server. The page shell goes out right away. The summary card and the transaction rows are patched in as their data becomes ready, all in the same HTTP response.

import http from 'node:http';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Fake data sources with different speeds
async function getSummary() {
  await sleep(300);
  return { volume: '1,204', successRate: '98.2%' };
}

async function* getTransactions() {
  for (const id of ['TX-1001', 'TX-1002', 'TX-1003']) {
    await sleep(500);
    yield id;
  }
}

http.createServer(async (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });

  // 1. Send the shell immediately, with named placeholders
  res.write(`<!doctype html>
<html>
<head><title>Dashboard</title></head>
<body>
  <header>My Dashboard</header>
  <section><?start name="summary">Loading summary…<?end></section>
  <table>
    <tbody><?start name="rows"><tr><td>Loading…</td></tr><?end></tbody>
  </table>
  <footer>Footer is here already!</footer>
`);

  // 2. Patch the summary as soon as it is ready
  const summary = await getSummary();
  res.write(`<template for="summary">
    <p>Volume: ${summary.volume} · Success rate: ${summary.successRate}</p>
  </template>`);

  // 3. Each row leaves a fresh marker for the next one
  for await (const id of getTransactions()) {
    res.write(`<template for="rows"><tr><td>${id}</td></tr><?marker name="rows"></template>`);
  }

  // 4. Close the document only after the last template
  res.end('</body></html>');
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

A few things to notice here:

  • The footer reaches the user before the slow data, even though it comes after the table in the layout.
  • The templates are written as direct children of <body>. This matters, as we'll see in the next section.
  • In a real app, escape any user data before writing it into the HTML.
  • Streaming only helps if the bytes actually reach the browser in chunks. Check that your compression middleware, proxy or CDN doesn't buffer the whole response.

The Chrome team also has a photo album demo that streams the status and the photos into the page after the initial layout.

Restrictions and gotchas

Out-of-order streaming has a few rules worth knowing before you use it:

  • Same parent only. For security reasons, a <template for> can only patch placeholders inside its own parent element. A template placed directly in <body> can reach the whole document, including the <head>.
  • <?end> is optional. Without it, everything from <?start> to the end of the containing element is replaced.
  • Don't move placeholders mid-stream. If you move a processing instruction after a template has started streaming into it, the new content keeps streaming into the old location.
  • innerHTML and setHTML() patch in place. When you insert <template for> with these methods, the parser works on an intermediate document fragment. So the template can't touch the existing DOM. The streaming methods in the next section don't have this limitation.
  • The response is the update window. Patching only works while the server is still writing the document. Once the response ends, further updates need JavaScript or a new navigation.

It's also worth being clear about what this is not. It doesn't replace client-side state. Filters, inline edits and anything else that reacts to user input after the page has loaded still needs JavaScript.

The new HTML insertion and streaming methods

Not every update can come from the initial HTML response. For those cases, we already have many ways to insert HTML with JavaScript: innerHTML, outerHTML, insertAdjacentHTML, createContextualFragment, setHTML and setHTMLUnsafe. Each of them behaves a little differently. Does it overwrite or append? Does it sanitize? Do scripts run? How does it work with Trusted Types? Honestly, I can't answer all of these without checking MDN.

There is also a bigger limitation. All these methods need the full HTML string up front. You can't stream into them, even though streaming is one of the best things about HTML.

The new set of methods fixes both problems, with consistent names and a streaming version of each:

Action Static Streaming
Set the contents of the element setHTML(html, options) streamHTML(options)
Replace the whole element replaceWithHTML(html, options) streamReplaceWithHTML(options)
Insert before the element beforeHTML(html, options) streamBeforeHTML(options)
Insert as the first child prependHTML(html, options) streamPrependHTML(options)
Insert as the last child appendHTML(html, options) streamAppendHTML(options)
Insert after the element afterHTML(html, options) streamAfterHTML(options)

Each method also has an Unsafe version, like streamHTMLUnsafe(). The safe versions run the HTML through the Sanitizer API by default. The Unsafe versions skip the sanitizer by default, and accept a runScripts option (default false) if you want scripts to execute. The word "unsafe" is a reminder to think about how much you trust the input, not a sign that you shouldn't use them.

Example 5: Static insertion with a custom sanitizer

const content = document.querySelector('#content');

// Only allow basic formatting
const sanitizer = new Sanitizer({ elements: ['em', 'i', 'b', 'strong'] });

content.setHTML('<p>Hello <strong>world</strong></p>', { sanitizer });
Enter fullscreen mode Exit fullscreen mode

Example 6: Streaming a fetch response into an element

const content = document.querySelector('#content');
const response = await fetch('/partials/transactions.html');

response.body
  .pipeThrough(new TextDecoderStream())
  .pipeTo(content.streamHTMLUnsafe());
Enter fullscreen mode Exit fullscreen mode

Chrome 151 also added a textStream() convenience method, which removes the TextDecoderStream step:

response.textStream().pipeTo(content.streamHTMLUnsafe());
Enter fullscreen mode Exit fullscreen mode

The nice part is that streamHTMLUnsafe() works much like the main parser. It processes <template for> as the templates arrive, and it doesn't use an intermediate fragment. So streamed templates can patch content that already exists in the page.

If you use Trusted Types, streaming needs a new createParserOptions method to inject a sanitizer. The explainer has the details.

Using both together: an SPA-style navigation

This is where things get interesting. The two APIs are separate, but they work really well together. If you stream <template for> elements into the page, each template finds its own placeholder. You don't need a separate DOM reference for every part of the page you want to update.

Here is the idea: load a page shell with named placeholders once. On navigation, fetch the next page as a set of templates and stream them into the bottom of <body>.

Example 7: The page shell

<!doctype html>
<html>
<head>
  <title><?start name="title">Dashboard<?end></title>
</head>
<body>
  <nav>
    <a href="/overview" data-partial>Overview</a>
    <a href="/transactions" data-partial>Transactions</a>
  </nav>

  <main><?start name="main">Loading…<?end></main>

  <script type="module" src="/router.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Example 8: A partial response from the server

Each template re-emits its own <?start> and <?end>, so the next navigation can replace the range again:

<template for="title"><?start name="title">Transactions<?end></template>

<template for="main">
  <?start name="main">
  <h1>Transactions</h1>
  <table>…</table>
  <?end>
</template>
Enter fullscreen mode Exit fullscreen mode

Example 9: The router

// router.js
document.addEventListener('click', async (event) => {
  const link = event.target.closest('a[data-partial]');
  if (!link) return;

  event.preventDefault();
  history.pushState({}, '', link.href);

  const response = await fetch(`/partials${link.pathname}`);

  // Templates land as direct children of <body>,
  // so they can patch anything in the document, including <head>
  await response.textStream().pipeTo(document.body.streamAppendHTMLUnsafe());
});
Enter fullscreen mode Exit fullscreen mode

Notice what's missing here. There is no client-side templating, no diffing, and no code that knows which element to update. The server decides what changes, and the HTML parser applies it as the bytes arrive. Because the templates are appended to <body>, the same-parent rule lets them update the <title> in the <head> too.

This is a sketch, not a production router. A real one would handle the back button, cancel an in-flight request when the user clicks again, and manage focus for accessibility. The Navigation API is a good fit for the first two.

Using it today with polyfills

The Chrome team has released two polyfills, so you don't have to wait for other browsers:

  • template-for-polyfill (npm) adds <template for> support. It can't change the browser's HTML parser, so it has some limitations, but it covers the common use cases.
  • html-setters-polyfill (npm) adds the new insertion and streaming methods. Note that it doesn't actually stream. It buffers the content and applies it when the stream completes. So treat it as a polyfill for the API shape, not for the performance benefit. The safe methods also depend on setHTML() and the Sanitizer API, which Safari doesn't support yet.

The good news is that out-of-order streaming degrades reasonably. Without support, the fallback content between <?start> and <?end> simply stays on the page. So test the experience in Firefox and Safari, and make sure the fallback is something the user can live with.

Browser support

Feature Chrome / Edge Firefox Safari
<template for> out-of-order streaming 150 (on by default) Not supported Not supported
New insertion and streaming methods Behind a flag from 148, planned for 155 Not supported Not supported
textStream() 151 Not supported Not supported

To try the streaming methods today, enable chrome://flags/#enable-experimental-web-platform-features. The for attribute on <template> is already part of the HTML standard, and the new methods are being added to the spec.

What's coming next

The Chrome team is exploring a few more additions:

  • Client-side includes, like <template for="footer" src="/partials/footer.html">. This one is already behind the experimental flag.
  • Versioning, so content that hasn't changed isn't overwritten and keeps its state between route changes.
  • Sanitizing while patching, like <template for="icon" safe>.

Additional Resources

Summary

For a long time, the order of the HTML document decided the order in which users saw things. Declarative Partial Updates change that. With <template for> and processing instructions, the server can send the fast parts of a page first and patch in the slow parts later, without any JavaScript. With the new streaming methods, JavaScript can stream HTML into an existing page too. Put them together and you get SPA-style updates where the server decides what changes and the browser's parser does the work.

For dashboards like the ones I work on, that means the user sees the page layout and the quick numbers right away, while the slow tables fill in as soon as their data is ready.

Top comments (0)