Atualizando uma página sem JavaScript
Envie atualizações HTML sem JavaScript usando template for e instruções de processamento. Veja o exemplo em Node, o suporte dos navegadores e as limitações.
Um elemento <template for="name"> preenche um placeholder marcado anteriormente no mesmo documento por uma processing instruction com esse nome. O servidor envia as seções lentas no final da mesma resposta HTML, e o parser do navegador as move para o lugar certo. Nenhum script é executado.
A maioria das páginas renderizadas no servidor tem uma região lenta, como uma contagem de pedidos ou uma verificação de status, e a solução habitual é uma div de carregamento mais um script que busca o fragmento depois. Esse script é a parte que muitas equipes gostariam de eliminar.
Este artigo mostra o markup, um servidor Node funcional que faz o streaming das atualizações, onde esse padrão se encaixa em relação ao htmx e o que os navegadores sem suporte renderizam.
Principais conclusões
<template for>substitui um intervalo<?start name="x">…<?end>, ou preenche um ponto de inserção<?marker name="x">, assim que o parser HTML chega até ele.- O servidor precisa fazer o flush da estrutura da página primeiro e escrever os templates depois, na mesma resposta. Qualquer camada que faça buffer da resposta inteira entrega o placeholder e seu substituto juntos.
- O Chrome 150 habilita o streaming fora de ordem por padrão. Os métodos JavaScript complementares, como
streamHTML, chegam no Chrome 155. Versões anteriores precisam de uma flag. - Navegadores sem suporte interpretam as processing instructions como comentários, de modo que o conteúdo de fallback permanece na tela.
O problema: buscar, localizar, substituir
Até agora, atualizar uma região de uma página renderizada no servidor exigia três etapas no cliente: buscar um fragmento, localizar o nó de destino e substituir seu conteúdo. Mesmo esta versão simplificada precisa de um script, uma segunda requisição e uma consulta ao DOM:
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
O servidor já sabia o status enquanto renderizava a primeira resposta. O round trip extra existe apenas porque o HTML sempre foi interpretado na ordem do documento.
Como as atualizações parciais declarativas funcionam no markup
As atualizações parciais declarativas usam processing instructions como âncoras nomeadas e <template for> como o patch. O anúncio do Chrome sobre atualizações parciais declarativas define três âncoras. <?marker name="x"> é um ponto de inserção. <?start name="x"> e <?end> delimitam um intervalo que é substituído.
Se o placeholder exibe conteúdo de fallback, use um par <?start>…<?end>. Quando o template correspondente chega, tudo entre as duas instruções é substituído. Um <?marker> isolado apenas marca um ponto, então um texto de fallback colocado ao lado dele nunca é removido.
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
Depois que o template é interpretado, o DOM fica assim:
<div id="status">All systems <strong>normal</strong></div>
A documentação do Chrome explica por que foram escolhidas processing instructions em vez de elementos: um patch feito por meio delas não deixa nós extras para trás, o que significa que ele pode funcionar em lugares onde novos elementos quebrariam a estrutura, como o <head> (para alterar o <title>, por exemplo) ou o interior de uma <table>.
O que o servidor envia?
O servidor escreve a estrutura inteira imediatamente, mantém a resposta aberta enquanto aguarda os dados e então escreve cada <template for> antes de fechar o documento. O exemplo abaixo usa apenas node:http e ES modules. Salve-o como server.mjs e execute 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 chamada a response.write() envia um chunk do corpo sem esperar pelo end(). Para entender melhor como consumir saída em chunks, veja o artigo web streams explained. As chamadas a sleep simulam consultas lentas. Elas não são um benchmark.
O helper esc() existe porque o conteúdo do template é HTML comum. Qualquer dado interpolado pode injetar markup, a menos que você faça o escape.
Quando o stream termina, #orders contém três elementos <li> seguidos de um <?marker name="orders"> final. Abra a página no Chrome 150 ou superior para ver a lista sendo preenchida.
O buffering anula o streaming fora de ordem
O streaming fora de ordem só ajuda se os bytes chegarem ao navegador ao longo do tempo. Três camadas comuns retêm a resposta inteira: middleware de compressão que espera o buffer encher, proxies reversos ou CDNs com buffering de resposta ativado e frameworks que renderizam a página inteira antes de enviar qualquer coisa. Execute curl -N http://localhost:3000 passando por cada camada para verificar se os chunks chegam separadamente. A flag -N desativa o buffering de saída do curl. O session replay mostra o resultado do ponto de vista do usuário, por exemplo, uma tela em branco que permanece vazia até que a página inteira chegue de uma só vez porque um proxy reteve o stream.
Casos de uso e seus limites
O patching declarativo só funciona enquanto o servidor ainda está escrevendo o documento, portanto o tempo de vida da resposta é a janela de atualização.
- Lista dinâmica. Terminar cada template com um novo
<?marker>de mesmo nome permite que templates posteriores continuem adicionando itens naquele ponto. A lista só continua dinâmica enquanto a conexão permanecer aberta. - Linha de status. Um único intervalo
<?start>/<?end>com um template, como no exemplo acima. - Resultado de formulário. Uma navegação
POSTcomum retorna uma página cuja estrutura é renderizada de imediato e cujo resultado chega via streaming quando o processamento termina.
Tudo o que reage a cliques depois que a página foi carregada (filtros, edições inline, UI otimista) ainda precisa de estado no cliente, JavaScript ou uma biblioteca.
Atualizações parciais declarativas vs htmx
O htmx envia novas requisições HTTP em resposta a eventos do usuário e insere os fragmentos retornados na página. <template for> funciona apenas dentro da resposta que já está em streaming. Depois que o servidor encerra essa resposta, novas atualizações exigem JavaScript ou uma nova navegação.
O htmx continua sendo uma biblioteca JavaScript. O htmx 4.0 adiciona uma tag <hx-partial>, que tem como alvo vários elementos a partir de uma única resposta. Padrões como scroll infinito com htmx, em que um elemento de carregamento que entra na área visível faz o htmx buscar a próxima página no seu servidor, e validação de formulários com htmx dependem de requisições disparadas pelo usuário.
| htmx | <template for> | |
|---|---|---|
| Gatilho | Evento do usuário | Ordem de escrita do servidor |
| Requisições | Uma por interação | Uma resposta |
| Executado em | Biblioteca JavaScript | Parser HTML |
| Alvos | Atributos com seletores CSS | Processing instructions nomeadas |
| Atualizações após o fim da resposta | Sim | Não |
Quando você precisa de JavaScript de qualquer forma
Para conteúdo que chega após o carregamento, o Chrome está adicionando métodos complementares como streamHTML e appendHTML, além de variantes Unsafe, e estendendo o setHTML já existente. Os desenvolvedores podem testá-los desde o Chrome 148 por meio de chrome://flags/#enable-experimental-web-platform-features, e eles estão incluídos no Chrome 155, que entrou em beta em 16 de setembro de 2026.
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() está disponível a partir do Chrome 151. Em versões mais antigas, use response.body.pipeThrough(new TextDecoderStream()) no lugar.
Quando você faz o pipe de markup através de streamHTMLUnsafe, qualquer <template for> contido nele aplica patches na página ativa. setHTML e innerHTML funcionam de outra forma: eles primeiro interpretam o conteúdo em um fragmento separado, então os templates dentro deles só conseguem aplicar patches em nós desse fragmento, e não na página existente.
Suporte dos navegadores, fallback e restrições
O streaming fora de ordem com <template for> e as processing instructions <?marker>, <?start> e <?end> está ativado por padrão a partir do Chrome 150, que chegou à versão estável em 30 de junho de 2026. O recurso é acompanhado no ChromeStatus, e o atributo for faz parte do WHATWG HTML Living Standard. Nenhum navegador fora do ecossistema Chromium o lançou. Na thread de standards-positions do WebKit, um engenheiro do WebKit propôs definir a posição do WebKit como “support”.
O Intent to Ship confirma que navegadores sem suporte interpretam as processing instructions como comentários, de modo que qualquer conteúdo de fallback permanece na tela. O conteúdo de um elemento <template> padrão não é renderizado, portanto os dados enviados via streaming nunca aparecem nesses navegadores.
| Chrome 150+ | Sem suporte | Com template-for-polyfill | |
|---|---|---|---|
| Texto de fallback | Substituído | Permanece visível | Substituído |
| Conteúdo via streaming | Aplicado via patch | Oculto no <template> | Aplicado via patch, com limitações do parser |
O Chrome publica o template-for-polyfill no npm. Ele cobre os casos mais comuns, mas não tem acesso ao parser nativo do navegador, então algumas limitações permanecem. Ele também é JavaScript, portanto uma página “sem JS” que precise funcionar em todos os navegadores ainda depende de um script. O html-setters-polyfill, um projeto separado, reproduz os nomes dos métodos das novas APIs, mas coleta todo o conteúdo primeiro e o insere no final, então não faz streaming.
Restrições do <template for> que você precisa conhecer:
- Um template só consegue alcançar processing instructions que compartilham o mesmo elemento pai. Posicione os templates diretamente sob o
<body>e eles poderão alcançar o documento inteiro, incluindo o<head>. - Você pode omitir o
<?end>. Nesse caso, a substituição vai do<?start>até o fechamento do elemento pai. - Se você mover uma processing instruction enquanto um template ainda está fazendo streaming para ela, o restante do conteúdo pode continuar sendo inserido no local antigo.
Conclusão
O streaming fora de ordem transfere a rotina de buscar, localizar e substituir para o parser HTML, mas apenas dentro de uma única resposta aberta. Onde quer que você o utilize, coloque os dados reais dentro de intervalos de fallback <?start>/<?end> para que navegadores sem suporte ainda exibam algo coerente. Em seguida, execute o server.mjs por trás do seu proxy e da sua CDN reais para verificar se os chunks chegam separadamente antes de depender disso em produção.
Perguntas frequentes
Qual é a diferença entre template for e o streaming com React Suspense?
O SSR com streaming do React envia cada boundary de Suspense resolvido como uma div oculta, seguida de um script inline que a coloca no lugar do fallback. Se os scripts estiverem desativados, ou se uma Content Security Policy bloquear scripts inline sem nonce, esse conteúdo nunca é movido para o lugar certo. Com template for, o próprio parser HTML faz a substituição, então nenhum script é executado e a página não precisa de permissão para scripts inline.
Existe uma forma compatível com todos os navegadores de fazer streaming de HTML fora de ordem sem JavaScript?
Sim. O Declarative Shadow DOM com slots nomeados funciona no Chrome, no Firefox e no Safari. A estrutura da página é um template com shadowrootmode='open' que contém elementos slot com conteúdo de fallback. Elementos com um atributo slot correspondente chegam depois via streaming e preenchem esses slots. A desvantagem é que o shadow root normalmente fica no body, então o CSS do documento não estiliza o markup da estrutura dentro da shadow tree.
O que acontece se uma consulta lenta falhar depois que a estrutura da página já foi enviada?
O status HTTP e os headers ficam definidos assim que o primeiro chunk é escrito, então o servidor não pode mais mudar para um erro 500, um redirecionamento ou novos cookies. Trate a falha no markup: escreva um template com o mesmo nome contendo uma mensagem de erro, e ele substituirá o fallback de carregamento. No Node, chamar res.setHeader depois que os headers foram enviados lança 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