Mettre à jour une page sans JavaScript
Diffusez des mises à jour HTML sans JavaScript avec template for et des instructions de traitement. Découvrez l’exemple Node, la compatibilité et les limites.
Un élément <template for="name"> remplit un emplacement réservé, signalé plus haut dans le même document par une instruction de traitement (processing instruction) portant ce nom. Le serveur envoie les sections lentes à la fin de la même réponse HTML, et le parseur du navigateur les met en place. Aucun script ne s’exécute.
La plupart des pages rendues côté serveur comportent une zone lente, comme un nombre de commandes ou une vérification d’état. La solution habituelle consiste à afficher une div de chargement, puis à récupérer le fragment après coup avec un script. C’est précisément ce script que de nombreuses équipes aimeraient supprimer.
Cet article présente le balisage, un serveur Node exécutable qui diffuse les mises à jour en streaming, la place de ce pattern par rapport à htmx, et ce qu’affichent les navigateurs qui ne le prennent pas en charge.
Points clés
<template for>remplace une plage<?start name="x">…<?end>, ou remplit un point d’insertion<?marker name="x">, au moment où le parseur HTML l’atteint.- Le serveur doit d’abord envoyer (flush) la structure de la page, puis écrire les templates plus loin dans la même réponse. Toute couche qui met en mémoire tampon l’intégralité de la réponse livre l’emplacement réservé et son remplacement en même temps.
- Chrome 150 active par défaut le streaming dans le désordre (out-of-order streaming). Les méthodes JavaScript associées, comme
streamHTML, arrivent dans Chrome 155. Les versions antérieures nécessitent un flag. - Les navigateurs sans prise en charge interprètent les instructions de traitement comme des commentaires : le contenu de repli reste donc affiché.
Le problème : récupérer, cibler, remplacer
Jusqu’à présent, mettre à jour une zone d’une page rendue côté serveur nécessitait trois étapes côté client : récupérer un fragment, trouver le nœud cible et remplacer son contenu. Même cette version minimale exige un script, une seconde requête et une recherche dans le DOM :
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
Le serveur connaissait déjà l’état pendant qu’il générait la première réponse. Cet aller-retour supplémentaire n’existe que parce que le HTML a toujours été analysé dans l’ordre du document.
Fonctionnement des mises à jour partielles déclaratives dans le balisage
Les mises à jour partielles déclaratives (declarative partial updates) utilisent des instructions de traitement comme ancres nommées et <template for> comme correctif. L’annonce de Chrome sur les mises à jour partielles déclaratives définit trois ancres. <?marker name="x"> est un point d’insertion. <?start name="x"> et <?end> encadrent une plage destinée à être remplacée.
Si l’emplacement réservé affiche un contenu de repli, utilisez une paire <?start>…<?end>. Lorsque le template correspondant arrive, tout ce qui se trouve entre les deux instructions est remplacé. Un <?marker> seul ne fait que marquer un point : un texte de repli placé à côté n’est donc jamais supprimé.
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
Une fois le template analysé, le DOM devient :
<div id="status">All systems <strong>normal</strong></div>
La documentation de Chrome explique pourquoi les instructions de traitement ont été préférées aux éléments : un correctif appliqué par leur biais ne laisse aucun nœud superflu, ce qui lui permet de fonctionner là où de nouveaux éléments poseraient problème, comme dans le <head> (pour modifier le <title>, par exemple) ou à l’intérieur d’une <table>.
Qu’envoie le serveur ?
Le serveur écrit immédiatement toute la structure de la page, maintient la réponse ouverte en attendant les données, puis écrit chaque <template for> avant de fermer le document. L’exemple ci-dessous utilise uniquement node:http et les modules ES. Enregistrez-le sous server.mjs et lancez 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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
Chaque appel à response.write() envoie un fragment (chunk) du corps de la réponse sans attendre end(). Pour en savoir plus sur la consommation d’une sortie fragmentée, consultez l’article web streams explained. Les appels à sleep simulent des requêtes lentes. Il ne s’agit pas d’un benchmark.
La fonction utilitaire esc() est indispensable, car le contenu d’un template est du HTML ordinaire. Toute donnée interpolée peut injecter du balisage si vous ne l’échappez pas.
À la fin du flux, #orders contient trois éléments <li> suivis d’un <?marker name="orders"> final. Ouvrez la page dans Chrome 150 ou une version ultérieure pour voir la liste se remplir.
La mise en mémoire tampon annule le streaming dans le désordre
Le streaming dans le désordre n’a d’intérêt que si les octets parviennent au navigateur au fil de l’eau. Trois couches courantes retiennent l’intégralité de la réponse : les middlewares de compression qui attendent un tampon complet, les reverse proxies ou CDN dont la mise en mémoire tampon des réponses est activée, et les frameworks qui génèrent toute la page avant d’envoyer quoi que ce soit. Exécutez curl -N http://localhost:3000 à travers chaque couche pour vérifier que les fragments arrivent séparément. Le flag -N désactive la mise en mémoire tampon de la sortie de curl. Le session replay montre le résultat du point de vue de l’utilisateur, par exemple un écran blanc qui reste vide jusqu’à ce que la page entière s’affiche d’un coup parce qu’un proxy a retenu le flux.
Cas d’usage et limites
Le patching déclaratif ne fonctionne que tant que le serveur écrit encore le document : la durée de vie de la réponse constitue donc la fenêtre de mise à jour.
- Liste dynamique. Terminer chaque template par un nouveau
<?marker>portant le même nom permet aux templates suivants de continuer à ajouter du contenu à cet endroit. La liste ne reste dynamique que tant que la connexion reste ouverte. - Ligne d’état. Une seule plage
<?start>/<?end>avec un seul template, comme dans l’exemple ci-dessus. - Résultat de formulaire. Une navigation
POSTclassique renvoie une page dont la structure s’affiche immédiatement et dont le résultat arrive en streaming une fois le traitement terminé.
Tout ce qui réagit aux clics après le chargement de la page (filtres, édition en ligne, UI optimiste) nécessite toujours un état côté client, du JavaScript ou une bibliothèque.
Mises à jour partielles déclaratives ou htmx
htmx envoie de nouvelles requêtes HTTP en réponse aux événements utilisateur et insère les fragments renvoyés dans la page. <template for> ne fonctionne qu’à l’intérieur de la réponse déjà en cours de streaming. Une fois que le serveur a terminé cette réponse, toute mise à jour supplémentaire nécessite du JavaScript ou une nouvelle navigation.
htmx reste une bibliothèque JavaScript. htmx 4.0 ajoute une balise <hx-partial>, qui cible plusieurs éléments à partir d’une seule réponse. Des patterns comme le défilement infini avec htmx, où l’apparition d’un élément de chargement à l’écran déclenche la récupération de la page suivante sur votre serveur, ou la validation de formulaires avec htmx, reposent sur des requêtes déclenchées par l’utilisateur.
| htmx | <template for> | |
|---|---|---|
| Déclencheur | Événement utilisateur | Ordre d’écriture du serveur |
| Requêtes | Une par interaction | Une seule réponse |
| Exécution | Bibliothèque JavaScript | Parseur HTML |
| Cibles | Attributs avec sélecteurs CSS | Instructions de traitement nommées |
| Mises à jour après la fin de la réponse | Oui | Non |
Quand JavaScript reste nécessaire
Pour le contenu qui arrive après le chargement, Chrome ajoute des méthodes associées comme streamHTML et appendHTML, ainsi que leurs variantes Unsafe, et étend la méthode existante setHTML. Les développeurs peuvent les tester depuis Chrome 148 via chrome://flags/#enable-experimental-web-platform-features, et elles sont incluses dans Chrome 155, passé en bêta le 16 septembre 2026.
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() est disponible à partir de Chrome 151. Dans les versions plus anciennes, utilisez plutôt response.body.pipeThrough(new TextDecoderStream()).
Lorsque vous faites transiter du balisage par streamHTMLUnsafe, tout <template for> qu’il contient modifie la page en direct. setHTML et innerHTML fonctionnent différemment : ils analysent d’abord le contenu dans un fragment distinct, si bien que les templates qu’ils contiennent ne peuvent modifier que les nœuds de ce fragment, et non la page existante.
Prise en charge par les navigateurs, repli et restrictions
Le streaming dans le désordre avec <template for> et les instructions de traitement <?marker>, <?start> et <?end> est activé par défaut à partir de Chrome 150, passé en version stable le 30 juin 2026. Il est suivi sur ChromeStatus et l’attribut for fait partie du WHATWG HTML Living Standard. Aucun navigateur non basé sur Chromium ne l’a encore livré. Dans le fil de discussion standards-positions de WebKit, un ingénieur WebKit a proposé d’arrêter la position de WebKit à « support ».
L’Intent to Ship confirme que les navigateurs sans prise en charge interprètent les instructions de traitement comme des commentaires, de sorte que tout contenu de repli reste affiché. Le contenu d’un élément <template> standard n’est pas rendu : les données diffusées n’apparaissent donc jamais dans ces navigateurs.
| Chrome 150+ | Sans prise en charge | Avec template-for-polyfill | |
|---|---|---|---|
| Texte de repli | Remplacé | Reste visible | Remplacé |
| Contenu diffusé | Inséré | Masqué dans <template> | Inséré, avec les limites du parseur |
Chrome publie template-for-polyfill sur npm. Ce polyfill gère les cas courants, mais il n’a aucun accès au parseur natif du navigateur, si bien que certaines limites subsistent. Il s’agit en outre de JavaScript : une page « sans JS » qui doit fonctionner dans tous les navigateurs dépend donc malgré tout d’un script. Le polyfill distinct html-setters-polyfill reprend les noms de méthodes des nouvelles API, mais il collecte d’abord l’intégralité du contenu et l’insère à la fin : il ne fait donc pas de streaming.
Restrictions à connaître concernant <template for> :
- Un template ne peut atteindre que les instructions de traitement qui partagent son élément parent. Placez les templates directement sous
<body>pour qu’ils puissent atteindre l’ensemble du document,<head>compris. - Vous pouvez omettre
<?end>. Dans ce cas, le remplacement s’étend de<?start>jusqu’à la fermeture de l’élément parent. - Si vous déplacez une instruction de traitement alors qu’un template est encore en cours de streaming vers celle-ci, le reste du contenu risque de continuer à arriver à l’ancien emplacement.
Conclusion
Le streaming dans le désordre transfère la routine « récupérer, cibler, remplacer » dans le parseur HTML, mais uniquement au sein d’une seule réponse ouverte. Partout où vous l’utilisez, placez les données réelles dans des plages de repli <?start>/<?end> afin que les navigateurs sans prise en charge affichent tout de même un contenu cohérent. Exécutez ensuite server.mjs derrière votre proxy et votre CDN réels pour vérifier que les fragments arrivent bien séparément avant de vous y fier en production.
FAQ
En quoi template for diffère-t-il du streaming avec React Suspense ?
Le SSR en streaming de React envoie chaque frontière Suspense résolue sous forme de div masquée, suivie d'un script inline qui la substitue au contenu de repli. Si les scripts sont désactivés, ou si une Content Security Policy bloque les scripts inline dépourvus de nonce, ce contenu n'est jamais mis en place. Avec template for, c'est le parseur HTML lui-même qui effectue le remplacement : aucun script ne s'exécute et la page n'a besoin d'aucune autorisation de script inline.
Existe-t-il une méthode compatible avec tous les navigateurs pour diffuser du HTML dans le désordre sans JavaScript ?
Oui. Le Declarative Shadow DOM avec des slots nommés fonctionne dans Chrome, Firefox et Safari. La structure de la page est un template avec shadowrootmode='open' contenant des éléments slot qui portent le contenu de repli. Les éléments dotés d'un attribut slot correspondant arrivent ensuite en streaming et remplissent ces slots. La contrepartie : la shadow root se trouve généralement sur body, si bien que le CSS au niveau du document ne s'applique pas au balisage de la structure situé dans l'arbre shadow.
Que se passe-t-il si une requête lente échoue après l'envoi de la structure de la page ?
Le statut HTTP et les en-têtes sont figés dès l'écriture du premier fragment : le serveur ne peut donc plus basculer vers une erreur 500, une redirection ou de nouveaux cookies. Gérez plutôt l'échec dans le balisage : écrivez un template portant le même nom et contenant un message d'erreur, qui remplacera le contenu de chargement. Dans Node, appeler res.setHeader après l'envoi des en-têtes lève l'erreur ERR_HTTP_HEADERS_SENT.
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