Declarative partial updates unlock a new Native Component Model

tip

Browsers are getting a native way to update parts of a page after it has already been sent: declarative partial updates.

You leave a marker in your HTML and fill it in later with a <template>. That "later" can arrive in any order, even streamed from your server as the data becomes ready.

In this article, we'll look at how the feature works, the use cases it's built for, and a new component model I think it quietly makes possible.

[SCREENCAST TODO: devtools screencast showing markers in an HTML document being filled out of order by streamed templates]

What declarative partial updates are

It starts with a marker. A marker is a placeholder you drop into your HTML that, on its own, renders nothing:

<main>
  <?marker name="content">
</main>

The <?marker> syntax is a processing instruction. When the browser parses it, it doesn't show anything. It just remembers the spot so you can fill it later.

You fill it with a <template> that points at the same name:

<template for="content">
  <p>Here is the content that goes in the marked spot.</p>
</template>

The for attribute matches the marker's name. When the browser reaches this <template>, it takes the content and drops it into the content marker. The <template> itself never renders. It's only the delivery mechanism.

Most of the time you'll want a loading state in the meantime. A range marker lets you wrap placeholder content between a start and an end:

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

Now the Loading… paragraph shows straight away. When the matching <template for="content"> arrives, it replaces everything between <?start> and <?end>.

Until now, doing this from the server wasn't really an option. You'd send the page, then reach for JavaScript on the client to patch it:

const html = await fetch("/partials/about")
  .then((response) => response.text());

document.querySelector("[name=content]").innerHTML = html;

That's an extra request once the page is already up, plus the JavaScript to wire it together. With markers and templates, the server streams the real content into the same response. No extra request, and no client-side code.

Streaming content out of order

Here's where it gets interesting. The <template> that fills a marker doesn't have to come right after it. It can come much later in the document, which means you can send your HTML in a different order than it appears on the page. That's what "out of order" means.

A common example is a mega menu: the big navigation panel at the top of a site. It sits early in the DOM, so the browser parses all of it before it reaches the content below. But the visitor can't see it until they interact with it, so it's holding up the important content (the Largest Contentful Paint, for example) for no reason.

With a marker, you put a small placeholder where the menu goes, send the main content first, and stream the menu's HTML at the end of the response:

<header>
  <?start name="menu">
    <!-- the menu fills in here -->
  <?end>
</header>

<main>
  <h1>Today's deals</h1>
  <p>The content the visitor actually came for, sent first. LCP candidate.</p>
</main>

<!-- streamed at the end of the response (or later) -->
<template for="menu">
  <nav><!-- a few hundred lines of menu markup --></nav>
</template>

This helps your Largest Contentful Paint (LCP), the moment the largest piece of content becomes visible. By sending the content the visitor came for before the menu, the browser can paint it sooner instead of waiting on markup nobody is looking at yet.

One thing to watch: if the menu fills in after the page has painted, it can push other elements around and hurt your Cumulative Layout Shift (CLS), the metric for unexpected movement. Reserve space for late-arriving content with CSS so the layout doesn't jump when it lands.

Keep your app shell, stream the page

This is the use case I'm most excited about. Think about the parts of an app that wrap your content: a navbar, a sidebar, maybe a player pinned to the bottom. They hold state. The sidebar has a section expanded. The search box has half-typed text in it. The audio player is mid-song.

A normal full-page navigation throws all of that away. Every click reloads the whole document, so the navbar and sidebar are rebuilt from scratch and lose their state. The usual fix is a single-page app, where a framework keeps the shell mounted and swaps only the content. But that means shipping and running that framework.

Markers give you the same result with plain HTML. You make the shell static and mark only the part that changes:

<body>
  <nav>
    <!-- navbar with its own state -->
  </nav>

  <aside>
    <!-- sidebar: an expanded section, a search box -->
  </aside>

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

Each navigation streams a new <template for="page"> with the next page's content. The browser replaces what's inside the page marker and touches nothing else.

Here's why that matters. Because the navbar and sidebar are never re-rendered, their DOM nodes keep their identity. And state lives on those nodes: the scroll position, the focus, the text in the search box, a running CSS animation, any JavaScript holding a reference to an element. Replace only the page marker, and all of it survives. This is the thing a single-page app uses a virtual DOM to pull off, for example. Here the browser does it for free, because you only patched one region.

Running behavior from JavaScript

So far everything has been declarative, with no JavaScript at all. There's one thing the declarative path won't do for you, though: run scripts.

For security and predictability, HTML that's inserted after parsing doesn't execute its <script> tags. That's true for innerHTML today, and it's true for content patched into a marker. If a streamed template contains a <script>, the browser ignores it by default.

When you do want that behavior, for example you're driving navigations from the client and the new content needs to wire itself up, there's an imperative API with an explicit opt-in:

// the container element whose content we'll replace (not a marker)
const main = document.querySelector("main");
const html = await fetch("/partials/about")
  .then((response) => response.text());

// runScripts: true opts in to executing <script> tags in the new HTML
main.setHTMLUnsafe(html, { runScripts: true });

Notice we call setHTMLUnsafe on <main> itself, not on a marker. That's the difference between the two paths: the declarative path fills a named marker, while setHTMLUnsafe is an element method that replaces the content of whatever element you call it on. The { runScripts: true } option is what lets the new markup bring its own behavior.

There's also a streaming version, streamHTMLUnsafe(). It returns a WritableStream, so you can pipe a fetch response straight into the page as it arrives, without waiting for the whole thing to download:

const response = await fetch("/partials/about");

// stream the response into the page as it arrives
response.body
  .pipeThrough(new TextDecoderStream())
  .pipeTo(main.streamHTMLUnsafe());

A fetch response body is a stream of bytes, but streamHTMLUnsafe() expects text, so TextDecoderStream sits in the middle and decodes the bytes as they flow through. The browser parses and shows the content chunk by chunk, so the visitor sees the start of the page before the server has finished sending the rest.

Note: The Unsafe suffix marks these as the non-sanitizing variant. The safe counterpart, setHTML(), runs the input through a sanitizer that strips dangerous bits like <script> tags and onclick handlers, so it's safe even with untrusted HTML. The Unsafe versions skip that step, so markup goes in uncleaned, much like the old innerHTML. They can also run scripts, but only if you opt in with runScripts: true, which defaults to false. So how unsafe this really is depends on how much you trust the input.

Introducing a new Native Component Model

Now put the pieces together, because this is where I think it gets genuinely new.

A <template for> can carry any HTML. That includes a <style> block and a <script>. So a single streamed template can hold a region's markup, its own scoped styles, and its own behavior, all in one unit:

<template for="main">
  <style>
    @scope {
      h1 {
        color: hotpink;
      }
      p {
        line-height: 1.6;
      }
    }
  </style>

  <h1>Hello world</h1>
  <p>This region styles and wires up itself.</p>

  <script>
    // behavior scoped to this component
    console.log("main component loaded");
  </script>
</template>

The interesting part is @scope. It's a CSS feature that limits a block of styles to a subtree instead of the whole page. Written bare like this, with no selector after @scope, it scopes to the parent element of the <style> block. Once the template is patched in, that parent is the main region itself. So the h1 and p rules apply here and nowhere else, even though they read like global selectors. No class-name conventions, no build step rewriting your selectors.

The <script> is the behavior for this region. As we just saw, it only runs through the imperative path with runScripts: true. A purely server-streamed template won't execute it yet. A declarative opt-in would close that gap nicely, something like a runscripts attribute on the <template>, and it's the piece I'd most like to see land. For now, the markup and styles are fully declarative, and the behavior needs that one JavaScript call.

Step back and look at what that template is. It's structure, style, and behavior for one piece of UI, written in plain HTML, scoped, and delivered by the browser itself. That's a component. We've had this shape for years in .vue and .svelte files, but always through a framework and a build step. Here there's neither. The wire format is the component.

To be clear about what's new: bundling structure, style, and behavior together isn't the new part, frameworks have done that for a long time. The new part is doing it with native browser primitives, no library involved, and streaming it into the page out of order.

How this differs from Declarative Shadow DOM

If you know the platform, one feature should be nagging at you. Declarative Shadow DOM (DSD) already lets you ship a component with co-located, scoped styles, server-rendered, no JavaScript. It's the one native precedent worth comparing against.

The difference is the kind of scoping. DSD uses the shadow DOM, which gives you strong encapsulation. That cuts both ways: your global theme and the normal cascade don't reach inside, and things like forms, focus, and ARIA references across the boundary get awkward. @scope is lighter. It keeps your styles local but still lets the page's styles cascade in. So this isn't a replacement for DSD. It's a second native option with a softer boundary, one that also streams out of order and can carry its own behavior.

Browser support

This is early. The feature ships in Chrome 148, and only behind a flag. Turn on chrome://flags/#enable-experimental-web-platform-features to try it. It is not Baseline, and you shouldn't ship it to production yet.

The encouraging part is the cross-engine signal. Firefox has given the proposal a positive standards position, and WebKit has signalled support too. It's rare to see all three engines leaning the same way this early, and it's a good sign this lands as a real, interoperable feature rather than a Chrome-only experiment.

Conclusion

Declarative partial updates start as a small idea, a marker you fill in later, but they reach further than that. They let your server stream HTML out of order to improve real metrics like LCP. They let you keep an app shell and its state without a single-page framework. And put together with @scope and runScripts, they sketch a native component model: structure, style, and behavior in one streamed unit, with no build step in sight.

It's behind a flag today, so treat the code here as something to experiment with rather than ship. But the direction is worth paying attention to.

Coming next

Markers and templates are really about one thing: letting the server decide what reaches the page, and when. That hints at a bigger shift, where your rendering isn't just sent from the server but actually aware of it. That's the subject of my next article, on server-aware partial updates. I'll link it here once it's out.


Updates

Updates and corrections will be added here as they come in. If you spot something, please let me know.

Check out my interactive courses