Streaming de HTML al DOM: diez años después, sin hacks

El navegador tiene uno de los parsers incrementales más optimizados que existen. Cuando cargás una página, el HTML se va renderizando a medida que llega por la red: no espera a tener el documento completo. Pero esa capacidad estaba reservada para la carga inicial. Una vez que la página arrancó, si querías meter HTML en un elemento, la opción de siempre era juntar el string entero y hacer innerHTML.
En diciembre de 2016, Jake Archibald abrió el issue whatwg/html#2142: "A way to stream content into an element". Quería hacer transiciones entre artículos sin perder el streaming. El workaround que usaba, y que más de uno copió, era crear un documento aparte, hacerle document.write() de cada chunk e ir moviendo los nodos al documento real. Funcionaba, pero era un hack.
Ese issue se cerró el 5 de noviembre de 2026, casi diez años después, y dos días después de que Chrome 157 llegara a estable con la solución.
Lo que había hasta ahora
Para meter HTML en el DOM había varias opciones, cada una con sus reglas:
innerHTMLyouterHTML: síncronos, todo o nada, los<script>no corren y no hay sanitización.insertAdjacentHTML('beforeend', html): lo mismo, con strings mágicos ('beforebegin','afterend'…) que no se parecen en nada abefore()oappend()del DOM.range.createContextualFragment(): la forma clásica de que los scripts corran, con una API poco amigable (desde hace poco también estásetHTMLUnsafe()conrunScripts).document.write(): el único streaming nativo, solo durante la carga y con todos sus problemas.setHTMLUnsafe()(Chrome 124) ysetHTML()con la Sanitizer API (Chrome 146): dos variantes más, con reglas propias.
Cada API llegó en una época distinta y nadie las pensó como un conjunto. En 2025, Noam Rosenthal abrió un issue en el WHATWG con un título bastante claro: "Coherent story for HTML-setting methods". La idea fue armar una matriz con tres dimensiones: seguro o inseguro, posición e inserción completa o streaming.
La API: 24 métodos con un patrón
El resultado son 24 métodos, que salen de cruzar esas tres dimensiones:
| Seguro (sanitiza) | Inseguro | |
|---|---|---|
| Reemplazar contenido | setHTML() |
setHTMLUnsafe() |
| Al final / al principio | appendHTML() / prependHTML() |
appendHTMLUnsafe() / prependHTMLUnsafe() |
| Antes / después del nodo | beforeHTML() / afterHTML() |
beforeHTMLUnsafe() / afterHTMLUnsafe() |
| Reemplazar el nodo | replaceWithHTML() |
replaceWithHTMLUnsafe() |
Y cada uno tiene su versión de streaming con el prefijo stream: streamHTML(), streamAppendHTML(), streamBeforeHTMLUnsafe(), etc., que en vez de recibir un string devuelven un WritableStream.
Hay algo que confunde en el texto oficial del anuncio, que dice que los métodos "posicionales" before(), after(), append() aceptan HTML. No es así: before() y append() siguen recibiendo nodos o texto, como siempre. Los nuevos son los que terminan en HTML.
Algo que me gustó: los métodos posicionales (beforeHTML, afterHTML, replaceWithHTML) no están solo en Element, también en Text y Comment. Así que un comentario puede funcionar como ancla:
<main>
<h1>Novedades</h1>
<!-- banner -->
<p>…</p>
</main>
const anchor = [...document.querySelector('main').childNodes]
.find((n) => n.nodeType === Node.COMMENT_NODE && n.data.trim() === 'banner');
anchor.replaceWithHTML(bannerMarkup);
El caso principal: fetch directo al DOM
El caso más directo es pedir un fragmento y meterlo en la página a medida que llega:
const res = await fetch('/fragments/comments');
await res.textStream().pipeTo(commentsSection.streamAppendHTML());
Response.textStream() es otra novedad reciente (estable desde la 151) que te da el body ya decodificado como stream de texto. El HTML se va parseando y agregando a medida que llega: si el servidor manda los primeros comentarios rápido y el resto tarda, el usuario ve los primeros enseguida.
El primer error que vas a cometer no tira ninguna excepción:
// ❌ No falla. Escribe "60,104,50,62,..." en la página
await res.body.pipeTo(element.streamAppendHTML());
res.body es un stream de bytes (Uint8Array), y el WritableStream convierte cada chunk a string con String(chunk). Un Uint8Array convertido a string es la lista de números separados por coma. La spec lo aclara: para bytes, primero hay que pasar por un TextDecoderStream, o usar textStream(), que hace eso mismo. Lo probé por las dudas, y sí, terminás con una página llena de números.
Una versión más completa, con fallback y cancelación:
const supportsStreaming = 'streamAppendHTMLUnsafe' in Element.prototype;
function toTextStream(res) {
return 'textStream' in res
? res.textStream()
: res.body.pipeThrough(new TextDecoderStream());
}
export async function loadInto(target, url, { signal } = {}) {
const res = await fetch(url, { signal, headers: { Accept: 'text/html' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!supportsStreaming) {
target.innerHTML = await res.text();
return;
}
// Recién ahora: streamHTMLUnsafe() vacía el target en el momento en que lo llamás
const sink = target.streamHTMLUnsafe();
try {
await toTextStream(res).pipeTo(sink, { signal });
return 'done';
} catch (err) {
// Lo que ya llegó queda en el DOM: avisamos para que la UI muestre "cortado"
if (signal?.aborted) return 'aborted';
throw err;
}
}
Comportamientos que conviene conocer
El algoritmo de streaming de la spec de HTML tiene varios comportamientos que conviene conocer antes de usarlo en serio. Armé un server de Node con respuestas lentas y probé cada uno en Chrome 157.
streamHTML() vacía el elemento apenas lo llamás, no cuando llega el primer chunk. Lo mismo streamReplaceWithHTML(), que saca el nodo al instante. Si lo llamás antes del fetch, el usuario ve un hueco vacío mientras espera la respuesta, con su posible layout shift. Por eso en el ejemplo de arriba el streamHTMLUnsafe() va después del await fetch().
Cancelar no deshace nada: lo probé abortando a los 300 ms y quedó en la página lo que había llegado. Si abortás el stream (el usuario tocó "cancelar", cambió de ruta), lo que ya se insertó queda en el DOM. Tu UI tiene que tener un estado para "respuesta cortada a la mitad".
Sacar el elemento del documento no cancela el stream. El parser sigue agregando nodos al elemento desconectado. Si el componente se desmonta, cancelá vos, con un AbortController atado a su ciclo de vida.
Con los métodos que tienen posición es distinto, porque guardan un nodo de referencia al momento de llamarlos: el propio nodo en streamBeforeHTML, el primer hijo en streamPrependHTML, y el hermano siguiente en streamAfterHTML y streamReplaceWithHTML. Si ese nodo deja de ser hijo del padre original mientras llega el contenido, el stream falla con HierarchyRequestError, y lo que ya se había insertado queda. Moverlo dentro del mismo padre no lo rompe. streamAppendHTML y streamHTML no tienen ancla, así que no les pasa.
Los tags partidos entre chunks no son un problema. Si un chunk termina en <scr y el siguiente empieza con ipt>, el tokenizer lo maneja. Nunca vas a ver un tag a medio llegar como texto.
Tampoco hay preload scanner. En la carga normal de una página, mientras un script bloquea el parser, el navegador mira hacia adelante para empezar a bajar imágenes y otros recursos. En el parser de fragmentos eso no pasa. Solo te afecta con runScripts y scripts bloqueantes: lo que viene detrás no se empieza a bajar hasta que el script corre.
Scripts y runScripts
Por default, los <script> que lleguen en el stream se insertan pero no corren nunca, igual que con innerHTML. Las variantes Unsafe aceptan una opción para cambiar eso:
await res.textStream().pipeTo(target.streamAppendHTMLUnsafe({ runScripts: true }));
Con el target conectado al documento, los scripts se comportan como en el parser principal: un <script src> clásico bloquea el parseo hasta que baja y corre, async corre cuando llega, y defer y type="module" corren cuando se cierra el stream. Las hojas de estilo pendientes del documento principal no bloquean los scripts streameados.
Ojo con las hojas de estilo que vienen dentro del fragmento. En Chrome 157, si con runScripts: true llega un <link rel="stylesheet"> y atrás un <script>, el script no corre y el parser se queda trabado: todo lo que venía después no aparece, y el pipeTo igual resuelve sin error. Si tus fragmentos traen CSS, mandalo aparte o en un <style>.
Acá hay algo que me sorprendió cuando lo probé. Armé un fragmento con un <p>, un <script src> que tarda medio segundo en llegar y otro <p> después. El parser se bloquea como corresponde: el segundo párrafo no aparece hasta que el script corre. Pero el await pipeTo() resolvió en un milisegundo, cuando todavía faltaban los 500 ms del script y el párrafo de abajo. Según la spec, cerrar el stream debería esperar a que se parsee todo y corran los scripts bloqueantes, así que esto parece un bug de Chrome y puede cambiar.
La contrapresión, en cambio, sí es así por spec: write() no espera a los scripts, así que si el parser está pausado esperando uno, el pipeTo sigue empujando chunks, que se acumulan en memoria. Y mientras el bug siga, si después del await hacés algo que depende del contenido (sacar un spinner, mover el foco, medir el alto), puede que el contenido todavía no esté completo. Con fragmentos sin scripts externos no lo vas a notar. Con runScripts, no confíes en el await para saber que terminó.
Y un clásico: un script streameado que hace document.write() escribe en el documento vivo. Como la página ya terminó de cargar, eso hace un document.open() implícito y te borra la página entera. Si tenés HTML legacy con document.write dando vueltas, no lo streamees con runScripts.
Las variantes seguras
Las variantes sin Unsafe pasan el contenido por el sanitizer de la Sanitizer API y sanitizan mientras parsean: en streaming, los nodos no llegan nunca al DOM vivo antes de pasar por el filtro.
El sanitizer por default trabaja con una lista de permitidos, y es más agresivo de lo que uno espera. Probé con esto:
list.appendHTML(
'<li class="x" onclick="alert(1)"><my-widget>w</my-widget><img src="https://tracker.example/p.gif">' +
'<a href="javascript:alert(1)">js</a><a href="/ok" target="_blank">ok</a></li>'
);
// Resultado: <li><a>js</a><a href="/ok">ok</a></li>
Se fueron el onclick y el javascript:, como era de esperar, pero también el class, el custom element, el <img> y el target. Tampoco está <details>. Antes de migrar, mirá qué permite con new Sanitizer().get() en la consola.
Para contenido que no controlás (una respuesta de un LLM, por ejemplo, que puede traer un link de phishing por prompt injection), lo mejor es una lista mínima y explícita:
const sanitizer = new Sanitizer({
elements: ['p', 'h3', 'ul', 'ol', 'li', 'pre', 'code', 'strong', 'em', 'blockquote',
{ name: 'a', attributes: ['href'] }],
attributes: [], // sin esto, cualquier atributo "seguro" pasa
dataAttributes: false,
});
await res.textStream().pipeTo(answer.streamAppendHTML({ sanitizer }));
Los dos últimos campos no son un detalle. Sin attributes: [] y dataAttributes: false, los elementos que no tienen lista propia aceptan cualquier atributo que el sanitizer considere seguro, incluidos style, id y data-*. En mis pruebas, un <p style="position:fixed;inset:0;background:url(...)"> pasaba entero: tapaba la UI y la url() salía a la red. Y el sanitizer no valida URLs: un href a un dominio de phishing pasa igual. Si ese es el riesgo, revisá los href vos (con una allowlist de dominios) o directamente no permitas <a>.
Si tu backend ya manda HTML (o convertís el Markdown a HTML de forma incremental, que es la parte difícil), esto crea cada nodo una sola vez, en vez de re-renderizar todo lo acumulado en cada chunk como hacen hoy muchas interfaces de chat. Y si el usuario selecciona texto del primer párrafo mientras sigue llegando el resto, la selección no se pierde. Sanitizar no evita requests, eso sí: un <img> permitido igual pega a la red. Para eso sigue haciendo falta una CSP con img-src. Y si el contenedor es una región aria-live, ponele aria-busy="true" mientras streameás y sacalo al terminar, para que el lector de pantalla no anuncie fragmentos sueltos.
Trusted Types
Si tu sitio usa Trusted Types con require-trusted-types-for 'script', las variantes de streaming Unsafe tiran un TypeError en el momento de llamarlas. Tiene sentido: el modelo de Trusted Types es que una policy revisa el string de HTML antes de insertarlo, pero en streaming no hay un string completo que revisar.
La solución es un método nuevo en las policies, createParserOptions, que en vez de aprobar el HTML aprueba las opciones del parser:
const fragments = trustedTypes.createPolicy('fragments', {
createParserOptions: (options) => ({ ...options, runScripts: true }),
});
const sink = target.streamHTMLUnsafe(fragments.createParserOptions({}));
await res.textStream().pipeTo(sink);
Con la CSP puesta, llamar a streamHTMLUnsafe() sin opciones tira This document requires 'TrustedHTMLParserOptions' assignment y deja el elemento como estaba. Con la policy, anda. Hasta ahora, combinar streaming con Trusted Types directamente no era posible.
Eso sí, el ejemplo es la policy más permisiva posible: cualquiera que tenga acceso a ella ejecuta scripts de lo que sea, lo que en la práctica es apagar Trusted Types para ese camino. Restringila (que solo la importe el módulo que carga fragmentos propios) o, mejor, usá una policy default con createParserOptions para inyectar un sanitizer o forzar runScripts: false. Una default así también hace que las llamadas sin opciones dejen de tirar error.
Migrar insertAdjacentHTML no es buscar y reemplazar
La tentación es pasar todos los insertAdjacentHTML('beforeend', x) a appendHTML(x). El equivalente más cercano, en realidad, es la versión Unsafe:
| Antes | Equivalente más cercano |
|---|---|
el.innerHTML = s |
el.setHTMLUnsafe(s) |
el.outerHTML = s |
el.replaceWithHTMLUnsafe(s) |
insertAdjacentHTML('beforebegin', s) |
el.beforeHTMLUnsafe(s) |
insertAdjacentHTML('afterbegin', s) |
el.prependHTMLUnsafe(s) |
insertAdjacentHTML('beforeend', s) |
el.appendHTMLUnsafe(s) |
insertAdjacentHTML('afterend', s) |
el.afterHTMLUnsafe(s) |
Más cercano, no idéntico: las versiones Unsafe procesan <template shadowrootmode> (Declarative Shadow DOM), y innerHTML e insertAdjacentHTML no.
Si pasás a la versión segura, que es lo que probablemente quieras con contenido que viene de usuarios, cambia el comportamiento: tu <my-widget> puede desaparecer porque el sanitizer no lo conoce. Hay que pasarle una configuración con elements.
Y hay casos que antes funcionaban y ahora tiran HierarchyRequestError. El más probable de cruzarte es insertar al lado de un nodo que es hijo directo de un elemento <template> (no de su .content, que sí funciona): insertAdjacentHTML lo deja pasar y beforeHTML no. Lo mismo con un nodo dentro de un DocumentFragment suelto. Y con los nodos sin padre pasa lo contrario: insertAdjacentHTML('beforebegin') tiraba NoModificationAllowedError, y beforeHTML() no hace nada en silencio (aunque streamBeforeHTML() sí tira error). Pasás de un error visible a una falla silenciosa.
Estado y fallback
El camino hasta el estable fue movido. El Intent to Ship se aprobó para Chrome 154 y la feature apareció en el post de la beta de 154. Después se desactivó en ese branch, siguió apagada en 155 y 156, y volvió en 157 después de una tanda de correcciones que salieron de la revisión de la spec. Por eso hoy vas a encontrar tablas de compatibilidad y artículos que dicen cosas distintas: la primera versión estable con esto habilitado es la 157.
Del lado de la spec, los métodos y la integración con Trusted Types ya están mergeados en HTML. Mozilla tiene posición positiva y el bug de implementación asignado. WebKit todavía no se expidió, aunque según el Intent to Ship participaron del diseño.
Para detectarlo:
const hasStreaming = 'streamAppendHTML' in Element.prototype;
const hasTextStream = 'textStream' in Response.prototype;
Hay un polyfill de Google Chrome Labs. En el Intent to Ship lo presentan como "fully functional", pero el propio README aclara que bufferea todo y lo inserta recién en close() (y que no soporta Trusted Types en streaming). O sea que te da la forma, pero no el beneficio. Si lo que buscás es que el usuario vea contenido antes, prefiero un fallback explícito a innerHTML: el resultado final es el mismo y queda claro que en ese navegador no hay streaming.
Conclusión
El streaming es lo que más llama la atención, pero a mí lo que más me gusta es que por fin hay una forma coherente de meter HTML en el DOM, con la versión segura como default.
Hoy haría dos cosas. Para código nuevo que inserta HTML de origen incierto, usaría las variantes seguras con un sanitizer explícito. Y para fragmentos del propio servidor (paneles, paginación, resultados de búsqueda) probaría el streamAppendHTMLUnsafe con fallback a innerHTML, sobre todo donde el servidor tarda en terminar la respuesta pero puede empezar a mandar rápido. Lo que no haría es un reemplazo masivo de insertAdjacentHTML: el cambio de comportamiento con el sanitizer va a aparecer en el peor momento.
Me imagino que htmx, Turbo y compañía van a empezar a apoyarse en esto en vez de en su propio procesamiento de fragmentos, al menos en Chromium. Y cuando Firefox lo saque (tiene posición positiva y un bug asignado, aunque sin fecha), va a dejar de ser una optimización para un solo navegador.

