Actualizar una página sin JavaScript
Transmite actualizaciones HTML sin JavaScript con template for e instrucciones de procesamiento. Consulta el ejemplo en Node, la compatibilidad y sus límites.
Un elemento <template for="name"> rellena un marcador de posición definido previamente en el mismo documento mediante una instrucción de procesamiento con ese nombre. El servidor envía las secciones lentas al final de la misma respuesta HTML y el parser del navegador las coloca en su sitio. No se ejecuta ningún script.
La mayoría de las páginas renderizadas en el servidor tienen una región lenta, como un contador de pedidos o una comprobación de estado. La solución habitual es un div de carga y un script que obtiene el fragmento después. Ese script es precisamente la parte que muchos equipos querrían eliminar.
Este artículo muestra el marcado, un servidor Node ejecutable que transmite las actualizaciones en streaming, el lugar que ocupa este patrón frente a htmx y lo que renderizan los navegadores que no lo admiten.
Puntos clave
<template for>reemplaza un rango<?start name="x">…<?end>, o rellena un punto de inserción<?marker name="x">, en el momento en que el parser HTML lo alcanza.- El servidor debe enviar primero el shell de la página y escribir los templates después, en la misma respuesta. Cualquier capa que almacene en búfer la respuesta completa entregará el marcador de posición y su reemplazo a la vez.
- Chrome 150 habilita el streaming fuera de orden de forma predeterminada. Los métodos JavaScript complementarios, como
streamHTML, llegan en Chrome 155. Las versiones anteriores requieren activar un flag. - Los navegadores sin soporte interpretan las instrucciones de procesamiento como comentarios, por lo que el contenido de respaldo permanece en pantalla.
El problema: obtener, localizar, reemplazar
Hasta ahora, actualizar una región de una página renderizada en el servidor requería tres pasos en el cliente: obtener un fragmento, localizar el nodo de destino y reemplazar su contenido. Incluso esta versión mínima necesita un script, una segunda petición y una búsqueda en el DOM:
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
El servidor ya conocía el estado mientras renderizaba la primera respuesta. El viaje de ida y vuelta adicional existe únicamente porque HTML siempre se ha analizado en el orden del documento.
Cómo funcionan las actualizaciones parciales declarativas en el marcado
Las actualizaciones parciales declarativas utilizan instrucciones de procesamiento como anclajes con nombre y <template for> como parche. El anuncio de Chrome sobre las actualizaciones parciales declarativas define tres anclajes. <?marker name="x"> es un punto de inserción. <?start name="x"> y <?end> delimitan un rango que se reemplaza.
Si el marcador de posición muestra contenido de respaldo, utiliza un par <?start>…<?end>. Cuando llega el template correspondiente, todo lo que hay entre ambas instrucciones se reemplaza. Un <?marker> aislado solo marca un punto, por lo que el texto de respaldo situado junto a él nunca se elimina.
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
Una vez analizado el template, el DOM queda así:
<div id="status">All systems <strong>normal</strong></div>
La documentación de Chrome explica por qué se eligieron instrucciones de procesamiento en lugar de elementos: un parche aplicado a través de ellas no deja nodos adicionales, lo que permite usarlo en lugares donde los nuevos elementos causarían problemas, como <head> (para cambiar el <title>, por ejemplo) o el interior de una <table>.
¿Qué envía el servidor?
El servidor escribe todo el shell de inmediato, mantiene la respuesta abierta mientras espera los datos y luego escribe cada <template for> antes de cerrar el documento. El siguiente ejemplo utiliza únicamente node:http y módulos ES. Guárdalo como server.mjs y ejecuta 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);
Cada llamada a response.write() envía un fragmento (chunk) del cuerpo sin esperar a end(). Para más información sobre cómo consumir salida fragmentada, consulta el artículo web streams explained. Las llamadas a sleep simulan consultas lentas. No constituyen un benchmark.
El helper esc() está ahí porque el contenido del template es HTML normal. Cualquier dato interpolado puede inyectar marcado si no se escapa.
Al terminar el stream, #orders contiene tres elementos <li> seguidos de un <?marker name="orders"> final. Abre la página en Chrome 150 o posterior para ver cómo se va rellenando la lista.
El almacenamiento en búfer anula el streaming fuera de orden
El streaming fuera de orden solo sirve si los bytes llegan al navegador de forma progresiva. Hay tres capas habituales que retienen la respuesta completa: el middleware de compresión que espera a llenar un búfer, los proxies inversos o CDN con el búfer de respuesta activado, y los frameworks que renderizan la página entera antes de enviar nada. Ejecuta curl -N http://localhost:3000 a través de cada capa para comprobar que los fragmentos llegan por separado. El flag -N desactiva el búfer de salida de curl. Session replay muestra el resultado desde la perspectiva del usuario; por ejemplo, una pantalla en blanco que permanece vacía hasta que la página completa llega de golpe porque un proxy retuvo el stream.
Casos de uso y sus límites
El parcheo declarativo solo funciona mientras el servidor sigue escribiendo el documento, de modo que la vida útil de la respuesta es la ventana de actualización.
- Lista en vivo. Terminar cada template con un nuevo
<?marker>del mismo nombre permite que los templates posteriores sigan añadiendo contenido en ese punto. La lista solo se mantiene en vivo mientras la conexión permanezca abierta. - Línea de estado. Un único rango
<?start>/<?end>con un template, como en el ejemplo anterior. - Resultado de formulario. Una navegación
POSTnormal devuelve una página cuyo shell se renderiza de inmediato y cuyo resultado llega en streaming cuando termina el procesamiento.
Cualquier cosa que reaccione a clics una vez cargada la página (filtros, edición en línea, UI optimista) sigue necesitando estado en el cliente, JavaScript o una librería.
Actualizaciones parciales declarativas frente a htmx
htmx envía nuevas peticiones HTTP en respuesta a eventos del usuario e inserta en la página los fragmentos devueltos. <template for> solo funciona dentro de la respuesta que ya se está transmitiendo. Una vez que el servidor cierra esa respuesta, cualquier actualización posterior requiere JavaScript o una nueva navegación.
htmx sigue siendo una librería JavaScript. htmx 4.0 añade una etiqueta <hx-partial> que permite actualizar varios elementos desde una sola respuesta. Patrones como el scroll infinito con htmx, en el que un elemento de carga que entra en el viewport hace que htmx solicite la siguiente página a tu servidor, o la validación de formularios con htmx, dependen de peticiones que desencadena el usuario.
| htmx | <template for> | |
|---|---|---|
| Desencadenante | Evento del usuario | Orden de escritura del servidor |
| Peticiones | Una por interacción | Una respuesta |
| Se ejecuta en | Librería JavaScript | Parser HTML |
| Destinos | Atributos con selectores CSS | Instrucciones de procesamiento con nombre |
| Actualizaciones tras finalizar la respuesta | Sí | No |
Cuando de todos modos necesitas JavaScript
Para el contenido que llega después de la carga, Chrome está incorporando métodos complementarios como streamHTML y appendHTML, además de sus variantes Unsafe, y está ampliando el ya existente setHTML. Los desarrolladores han podido probarlos desde Chrome 148 activando chrome://flags/#enable-experimental-web-platform-features, y están incluidos en Chrome 155, que entró en beta el 16 de septiembre de 2026.
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() está disponible a partir de Chrome 151. En versiones anteriores, utiliza response.body.pipeThrough(new TextDecoderStream()) en su lugar.
Cuando canalizas marcado a través de streamHTMLUnsafe, cualquier <template for> que contenga parchea la página en vivo. setHTML e innerHTML funcionan de otro modo: primero analizan el contenido en un fragmento independiente, por lo que los templates que incluyan solo pueden parchear nodos dentro de ese fragmento, no de la página existente.
Compatibilidad con navegadores, contenido de respaldo y restricciones
El streaming fuera de orden con <template for> y las instrucciones de procesamiento <?marker>, <?start> y <?end> está activado de forma predeterminada desde Chrome 150, que llegó a la versión estable el 30 de junio de 2026. Se puede seguir en ChromeStatus y el atributo for forma parte del WHATWG HTML Living Standard. Ningún navegador que no esté basado en Chromium lo ha implementado. En el hilo de standards-positions de WebKit, un ingeniero de WebKit propuso fijar la postura de WebKit como “support”.
El Intent to Ship confirma que los navegadores sin soporte interpretan las instrucciones de procesamiento como comentarios, por lo que cualquier contenido de respaldo permanece en pantalla. El contenido de un elemento <template> estándar no se renderiza, así que los datos transmitidos nunca aparecen en esos navegadores.
| Chrome 150+ | Sin soporte | Con template-for-polyfill | |
|---|---|---|---|
| Texto de respaldo | Reemplazado | Permanece visible | Reemplazado |
| Contenido transmitido | Insertado mediante parche | Oculto en <template> | Insertado mediante parche, con límites del parser |
Chrome publica template-for-polyfill en npm. Cubre los casos habituales, pero no tiene acceso al parser propio del navegador, por lo que persisten algunas limitaciones. Además, es JavaScript, de modo que una página “sin JS” que deba funcionar en todos los navegadores sigue dependiendo de un script. El polyfill independiente html-setters-polyfill replica los nombres de los métodos de las nuevas API, pero recopila todo el contenido primero y lo inserta al final, por lo que no hace streaming.
Restricciones de <template for> que conviene conocer:
- Un template solo puede alcanzar instrucciones de procesamiento que compartan su elemento padre. Si colocas los templates directamente bajo
<body>, podrán alcanzar todo el documento, incluido<head>. - Puedes omitir
<?end>. En ese caso, el reemplazo abarca desde<?start>hasta el cierre del elemento padre. - Si mueves una instrucción de procesamiento mientras un template todavía está transmitiendo contenido hacia ella, el resto del contenido puede seguir llegando a la ubicación anterior.
Conclusión
El streaming fuera de orden traslada la rutina de obtener, localizar y reemplazar al parser HTML, pero solo dentro de una única respuesta abierta. Allí donde lo utilices, coloca los datos reales dentro de rangos de respaldo <?start>/<?end> para que los navegadores sin soporte sigan mostrando algo coherente. Después, ejecuta server.mjs detrás de tu proxy y tu CDN reales para comprobar que los fragmentos llegan por separado antes de confiar en ello en producción.
Preguntas frecuentes
¿En qué se diferencia template for del streaming con React Suspense?
El SSR con streaming de React envía cada boundary de Suspense resuelto como un div oculto, seguido de un script inline que lo intercambia por el contenido de respaldo. Si los scripts están desactivados, o una Content Security Policy bloquea los scripts inline sin nonce, ese contenido nunca se coloca en su sitio. Con template for, es el propio parser HTML quien realiza el intercambio, por lo que no se ejecuta ningún script y la página no necesita permitir scripts inline.
¿Existe una forma compatible con todos los navegadores de transmitir HTML fuera de orden sin JavaScript?
Sí. Declarative Shadow DOM con slots con nombre funciona en Chrome, Firefox y Safari. El shell es un template con shadowrootmode='open' que contiene elementos slot con contenido de respaldo. Los elementos con un atributo slot coincidente llegan más tarde en el stream y rellenan esos slots. La contrapartida es que el shadow root suele situarse en body, por lo que el CSS a nivel de documento no aplica estilos al marcado del shell dentro del shadow tree.
¿Qué ocurre si una consulta lenta falla después de enviar el shell de la página?
El código de estado HTTP y las cabeceras quedan fijados en cuanto se escribe el primer fragmento, por lo que el servidor ya no puede cambiar a un error 500, una redirección o nuevas cookies. Gestiona el fallo en el marcado: escribe un template con el mismo nombre que contenga un mensaje de error y este reemplazará el contenido de carga. En Node, llamar a res.setHeader después de enviar las cabeceras lanza 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