← Home

Server-aware partial updates

A twist on the app-shell idea from the article: what if the server decided how much to send, based on how the page was requested?

Picture an app shell (a navbar and some state) with one marked region for the page. Click /about from inside the running app and the server sends back just a <template for="page">, the patch that fills the marked region. Request the same /about directly (type it in, or hard-refresh) and you get the whole document: the app shell with that same patch streamed right behind it. Same URL, two responses, one patch.

How does the server know the difference? The browser tells it. A full navigation carries Sec-Fetch-Mode: navigate; a fetch() from your code carries cors or same-origin. The server branches on that one header.

Live demo

The frame loads Home as a full document, which is a navigation. Click the nav links for in-app navigation: only the page region streams in, and the load timestamp (stamped when the shell is built, so it only changes on a full render) stays put. Hit ↻ reload in the frame's address bar to force a full request and watch the timestamp change.

Server: one URL, two responses
// The same URL, /about, served two ways. The patch is identical;
// a direct request just gets the app shell streamed in front of it.

// The patch re-emits its own <?start>/<?end> range. A marker is consumed
// when it's patched, so every update has to leave a fresh one behind for
// the next one to fill.
const patch =
  `<template for="page"><?start name="page">${pageContent}<?end></template>`;

// Browsers send "Sec-Fetch-Mode: navigate" on a full page load,
// but "cors" / "same-origin" on a fetch(), so the server can tell them apart.
if (request.headers.get("Sec-Fetch-Mode") === "navigate") {
  return shell + patch; // shell streams first, then the patch fills the marker
}

return patch; // in-app request: just the patch
Client: in-app navigation
// Intercept in-app links and fetch the next page from the server.
link.addEventListener("click", async (event) => {
  event.preventDefault();
  const response = await fetch(link.href); // a fetch, not a navigation

  // Append the streamed <template for="page"> into <main>. It finds the
  // page marker already in the DOM and replaces only that region. The
  // navbar, its state, and its DOM identity are all left untouched.
  await response.body
    .pipeThrough(new TextDecoderStream())
    .pipeTo(main.streamAppendHTMLUnsafe());
});
Demo site
Server response (live) waiting…
Interact with the demo to see what the server sends.

      

Registering service worker…

How this works

Load a page directly and the server sends a complete document: the app shell, a page marker showing a brief loading state, and a <template for="page"> that fills the marker with the page's content. The shell paints first, then the content drops in.

Navigate inside the app and the server sends back just that <template for="page">, with no shell. It fills the marker that's already on the page and replaces only that region, so the navbar, the load timestamp, and your typed text all stay exactly as they were. The same patch either rides in with the shell (your first visit) or arrives on its own (every visit after).

One detail that makes the "every visit after" part work: a marker is consumed when it's patched. Patching the <?start name="page">…<?end> range replaces the range itself, not just what's inside it, so a patch that carried only the page's content would leave nothing behind for the next navigation to fill. That's why the server wraps every patch in a fresh <?start name="page">…<?end> range: each update hands the next one a new marker to aim at.