12k
All articles

Updating a Page Without JavaScript

Stream HTML updates without JavaScript using template for and processing instructions. See the Node server example, browser support, fallback behavior, and limits.

OpenReplay Team
OpenReplay Team
Updating a Page Without JavaScript

A <template for="name"> element fills a placeholder marked earlier in the same document by a processing instruction with that name. The server sends the slow sections at the end of the same HTML response, and the browser’s parser moves them into place. No script runs.

Most server-rendered pages have one slow region, such as an order count or a status check, and the usual fix is a loading div plus a script that fetches the fragment afterwards. That script is the part many teams would like to delete.

This article shows the markup, a runnable Node server that streams the updates, where the pattern fits next to htmx, and what browsers without support render.

Key Takeaways

  • <template for> replaces a <?start name="x">…<?end> range, or fills a <?marker name="x"> insertion point, as the HTML parser reaches it.
  • The server has to flush the page shell first and write templates later in the same response. Any layer that buffers the whole response delivers the placeholder and its replacement together.
  • Chrome 150 enables out-of-order streaming by default. The companion JavaScript methods such as streamHTML arrive in Chrome 155. Earlier versions need a flag.
  • Browsers without support parse the processing instructions as comments, so the fallback content stays on screen.

The Problem: Fetch, Find, Swap

Until now, updating one region of a server-rendered page took three client-side steps: fetch a fragment, find the target node, and swap its contents. Even this small version needs a script, a second request and a DOM lookup:

<div id="status">Loading…</div>
<script type="module">
  const res = await fetch('/status.html');
  document.querySelector('#status').innerHTML = await res.text();
</script>

The server already knew the status while it was rendering the first response. The extra round trip exists only because HTML has always been parsed in document order.

How Declarative Partial Updates Work in Markup

Declarative partial updates use processing instructions as named anchors and <template for> as the patch. Chrome’s declarative partial updates announcement defines three anchors. <?marker name="x"> is an insertion point. <?start name="x"> and <?end> wrap a range that gets replaced.

If the placeholder shows fallback content, use a <?start>…<?end> pair. When the matching template arrives, everything between the two instructions is replaced. A bare <?marker> only marks a point, so fallback text placed next to it is never removed.

<div id="status"><?start name="status">Loading…<?end></div>

<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>

Once the template is parsed, the DOM becomes:

<div id="status">All systems <strong>normal</strong></div>

Chrome’s documentation explains why processing instructions were chosen over elements: a patch through them leaves no extra nodes behind, which means it can work in places where new elements would break things, such as <head> (to change the <title>, for example) or the inside of a <table>.

What Does the Server Send?

The server writes the whole shell immediately, keeps the response open while it waits for data, and then writes each <template for> before closing the document. The example below uses only node:http and ES modules. Save it as server.mjs and run node server.mjs:

// server.mjs — run with: node server.mjs, then open http://localhost:3000
import { createServer } from 'node:http';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

async function getStatus() { await sleep(1000); return 'All systems normal'; }
async function* getOrders() {
  for (const o of ['Order #1001', 'Order #1002', 'Order #1003']) {
    await sleep(700);
    yield o;
  }
}

createServer(async (req, res) => {
  if (req.url !== '/') { res.writeHead(404).end(); return; }

  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'no-store',
  });

  // 1. Flush the shell immediately.
  res.write(`<!doctype html>
<html><head><meta charset="utf-8"><title>Orders</title></head>
<body>
  <h1>Your orders</h1>
  <p id="status"><?start name="status">Checking status…<?end></p>
  <ul id="orders"><?start name="orders"><li>Loading orders…</li><?end></ul>
`);

  // 2. Templates are direct children of <body>, so they can reach the placeholders above.
  const status = await getStatus();
  res.write(`<template for="status">${esc(status)}</template>\n`);

  // 3. Each template appends one item and leaves a fresh marker for the next.
  for await (const order of getOrders()) {
    res.write(`<template for="orders"><li>${esc(order)}</li><?marker name="orders"></template>\n`);
  }

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

Each call to response.write() sends a chunk of the body without waiting for end(). For background on consuming chunked output, see the web streams explained piece. The sleep calls stand in for slow queries. They are not a benchmark.

The esc() helper is there because template content is ordinary HTML. Any interpolated data can inject markup unless you escape it.

When the stream finishes, #orders contains three <li> elements followed by a trailing <?marker name="orders">. Open the page in Chrome 150 or later to watch the list fill in.

Buffering Defeats Out-of-Order Streaming

Out-of-order streaming only helps if the bytes reach the browser over time. Three common layers hold the whole response back: compression middleware that waits for a full buffer, reverse proxies or CDNs with response buffering switched on, and frameworks that render the entire page before sending anything. Run curl -N http://localhost:3000 through each layer to check that chunks arrive separately. The -N flag disables curl’s output buffering. Session replay shows the result from the user’s side, for example a blank screen that stays empty until the whole page lands at once because a proxy held the stream.

Use Cases and Their Limits

Declarative patching works only while the server is still writing the document, so the response lifetime is the update window.

  • Live list. Ending each template with a fresh <?marker> of the same name lets later templates keep appending at that spot. The list stays live only as long as the connection stays open.
  • Status line. A single <?start>/<?end> range with one template, as in the example above.
  • Form result. A normal POST navigation returns a page whose shell renders at once and whose result streams in when processing finishes.

Anything that reacts to clicks after the page has loaded (filters, inline edits, optimistic UI) still needs client state, JavaScript or a library.

Declarative Partial Updates vs htmx

htmx sends new HTTP requests in response to user events and swaps the returned fragments into the page. <template for> works only inside the response that is already streaming. Once the server ends that response, further updates need JavaScript or a new navigation.

htmx remains a JavaScript library. htmx 4.0 adds an <hx-partial> tag, which targets several elements from one response. Patterns such as infinite scroll with htmx, where a loader element scrolling into view makes htmx fetch the next page from your server, and form validation with htmx depend on requests the user triggers.

htmx<template for>
TriggerUser eventServer write order
RequestsOne per interactionOne response
Runs inJavaScript libraryHTML parser
TargetsCSS selector attributesNamed processing instructions
Updates after response endsYesNo

When You Need JavaScript Anyway

For content that arrives after load, Chrome is adding companion methods such as streamHTML and appendHTML, plus Unsafe variants, and extending the existing setHTML. Developers have been able to test them since Chrome 148 behind chrome://flags/#enable-experimental-web-platform-features, and they are included in Chrome 155, which entered beta on September 16, 2026.

const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());

response.textStream() is available from Chrome 151. In older versions, use response.body.pipeThrough(new TextDecoderStream()) instead.

When you pipe markup through streamHTMLUnsafe, any <template for> inside it patches the live page. setHTML and innerHTML work differently: they parse into a separate fragment first, so templates inside them can only patch nodes within that fragment, not the existing page.

Browser Support, Fallback and Restrictions

Out-of-order streaming with <template for> and the <?marker>, <?start> and <?end> processing instructions is on by default from Chrome 150, which reached stable on June 30, 2026. It is tracked on ChromeStatus and the for attribute is part of the WHATWG HTML Living Standard. No non-Chromium browser has shipped it. In the WebKit standards-positions thread, a WebKit engineer proposed resolving WebKit’s position as “support”.

The Intent to Ship confirms that browsers without support parse the processing instructions as comments, so any fallback content stays on screen. A standard <template> element’s contents are not rendered, so the streamed data never appears in those browsers.

Chrome 150+No supportWith template-for-polyfill
Fallback textReplacedStays visibleReplaced
Streamed contentPatched inHidden in <template>Patched in, with parser limits

Chrome publishes template-for-polyfill on npm. It handles the usual cases, but it has no way into the browser’s own parser, so some limits remain. It is also JavaScript, so a “no JS” page that needs to work in every browser still depends on a script. The separate html-setters-polyfill copies the method names of the new APIs, but it collects all the content first and inserts it at the end, so it does not stream.

Restrictions on <template for> to know about:

  • A template can only reach processing instructions that share its parent element. Place templates directly under <body> and they can reach the whole document, <head> included.
  • You can leave out <?end>. If you do, the replacement runs from <?start> to the close of the parent element.
  • If you move a processing instruction while a template is still streaming into it, the rest of the content may keep landing at the old spot.

Conclusion

Out-of-order streaming moves the fetch-find-swap routine into the HTML parser, but only inside a single open response. Wherever you use it, put the real data inside <?start>/<?end> fallback ranges so browsers without support still show something sensible. Then run server.mjs behind your actual proxy and CDN to check whether chunks arrive separately before you rely on it in production.

FAQs

How is template for different from React Suspense streaming?

React streaming SSR sends each resolved Suspense boundary as a hidden div, followed by an inline script that swaps it in for the fallback. If scripts are disabled, or a Content Security Policy blocks inline scripts that have no nonce, that content never moves into place. With template for, the HTML parser does the swap itself, so no script runs and the page needs no inline script allowance.

Is there a cross-browser way to stream HTML out of order without JavaScript?

Yes. Declarative Shadow DOM with named slots works in Chrome, Firefox and Safari. The shell is a template with shadowrootmode='open' that contains slot elements holding fallback content. Elements with a matching slot attribute stream in later and fill those slots. The trade-off is that the shadow root usually sits on body, so document-level CSS does not style the shell markup inside the shadow tree.

What happens if a slow query fails after the page shell is sent?

The HTTP status and headers are fixed as soon as the first chunk is written, so the server can no longer switch to a 500 error, a redirect or new cookies. Handle the failure in markup instead: write a template with the same name that contains an error message, and it replaces the loading fallback. In Node, calling res.setHeader after the headers are sent throws ERR_HTTP_HEADERS_SENT.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.