3

Criei um app em tempo real com 2,4 KB de JavaScript — sem framework, sem build

HTML sobre WebSockets: o servidor monta o HTML e manda pronto, o cliente só troca os pedaços na tela. Um dashboard em tempo real sem framework nenhum.


Quando a gente precisa de "tempo real" no front-end, a resposta padrão hoje é sempre a mesma: framework SPA, biblioteca de estado, virtual DOM, bundler, hidratação. Isso tudo faz sentido em produto grande. Mas tem uma pergunta que incomoda:

Se o servidor já sabe o que mudou... por que mandar uma engine de renderização para o navegador descobrir isso de novo?

Existe um jeito mais antigo de resolver isso: montar o HTML no servidor e mandar pronto via WebSocket. O cliente não parseia JSON, não faz diff, não renderiza nada. Ele recebe o HTML pronto e só coloca no lugar.

Para testar até onde essa ideia vai em 2026, fiz um dashboard completo de tickets de suporte em tempo real — estatísticas ao vivo, criar/editar/remover sincronizado entre várias abas — sem framework nenhum. Todo o JavaScript do navegador soma 2,4 KB. Neste artigo mostro como funciona.

A arquitetura

┌──────────────┐    o que o usuário fez   ┌──────────────────┐
│   Navegador  │ ───────────────────────▶ │     Servidor     │
│              │        (JSON)            │                  │
│  troca peda- │ ◀─────────────────────── │  guarda o estado │
│  ços de HTML │    HTML pronto           │  e monta o HTML  │
└──────────────┘                          └──────────────────┘

Dois caminhos:

  1. Subindo (cliente → servidor): mensagens JSON pequenas dizendo o que o usuário fez{action: 'change-status', id: 2, status: 'done'}.
  2. Descendo (servidor → cliente): HTML pronto. O servidor guarda todo o estado; quando algo muda, ele monta só o pedaço de HTML que mudou e envia para todo mundo conectado.

Esse segundo ponto é o melhor da história: como o servidor manda o HTML já pronto (e não dados para o cliente montar), a sincronização entre usuários sai quase de graça. Existe uma única fonte da verdade, e toda aba aberta acompanha ela.

O cliente inteiro

Este é todo o JavaScript da aplicação. Não é resumo — é o arquivo completo:

// Um único WebSocket para a página toda.
const socket = new WebSocket(`ws://${location.host}`);

// O servidor manda pedaços de HTML; a gente aplica com regras simples.
socket.onmessage = (event) => {
  const tpl = document.createElement('template');
  tpl.innerHTML = event.data;

  // Se veio um elemento com id, troca pelo de mesmo id na página.
  for (const node of [...tpl.content.querySelectorAll('[id]')]) {
    const existing = document.getElementById(node.id);
    if (existing) existing.replaceWith(node);
  }
  // Linha para apagar: veio vazia com data-delete-id, acha e remove.
  for (const marker of [...tpl.content.querySelectorAll('[data-delete-id]')]) {
    document.querySelector(`[data-row-id="${marker.dataset.deleteId}"]`)?.remove();
  }
  // Linha para atualizar: substitui só aquela linha.
  for (const fresh of [...tpl.content.querySelectorAll('[data-update-id]')]) {
    const stale = document.querySelector(`[data-row-id="${fresh.dataset.updateId}"]`);
    if (stale) {
      fresh.removeAttribute('data-update-id');
      const focused = stale.contains(document.activeElement);
      stale.replaceWith(fresh);
      if (focused) fresh.querySelector('select')?.focus();
    }
  }
};

function send(msg) {
  socket.send(JSON.stringify(msg));
}

// Eventos no document: funciona para qualquer elemento, mesmo os que
// chegaram agora. Cada elemento diz o que faz com data-action.
document.addEventListener('submit', (e) => {
  if (e.target.id === 'new-ticket') {
    e.preventDefault();
    const fd = new FormData(e.target);
    send({ action: 'add', title: fd.get('title'), priority: fd.get('priority') });
    e.target.reset();
  }
});

document.addEventListener('change', (e) => {
  if (e.target.dataset.action === 'change-status') {
    send({ action: 'change-status', id: e.target.dataset.id, status: e.target.value });
  }
});

document.addEventListener('click', (e) => {
  if (e.target.dataset.action === 'delete') {
    send({ action: 'delete', id: e.target.dataset.id });
  }
});

É só isso. Três ideias resolvem tudo:

1. Se tem id, troca pelo novo

Quando uma estatística muda, o servidor manda <div id="stats">...</div>. O cliente acha o #stats que já existe na página e substitui. Quando a tabela inteira muda, manda um <tbody id="tickets"> novo. A regra é a mesma para tudo: o servidor diz "esse pedaço da tela agora é esse aqui". Sem diff, sem keys, sem algoritmo de reconciliação.

2. Para apagar, manda um marcador

Não dá para "trocar" algo que precisa desaparecer, então a remoção chega como uma linha vazia com data-delete-id. O cliente lê e remove a linha correspondente. Duas linhas de código, e continua sendo HTML — sem inventar formato de mensagem.

Confesso que essa foi a única volta que dei fazendo a demo: na primeira versão eu tentava mandar <script> para executar a remoção, mas script inserido via innerHTML não roda. O marcador resolveu o problema e ainda deixou o design mais simples — o cliente segue regras fixas em vez de executar código que veio da rede.

Depois apareceu um terceiro caso: mudar o status de uma linha precisava atualizar só aquela linha, senão a etiqueta de status dessincronizava do seletor. Virou a regra do data-update-id — mesma ideia, mais uma regra na lista.

3. Eventos no document, não em cada elemento

Em vez de pendurar um listener em cada botão (e ter que pendurar de novo toda vez que o HTML troca), são só três listeners no document pegando os eventos de submit, change e click. Cada elemento diz o que faz com um data-action:

<select data-action="change-status" data-id="2">
<button data-action="delete" data-id="2">

Como os listeners estão no document, qualquer HTML que chegar já funciona na hora. Sem precisar "ligar" nada.

O servidor

O servidor é um único arquivo Node (~180 linhas) e a única dependência é o ws. Ele faz três coisas:

Monta o HTML — funções simples que devolvem string, cada uma dona de um pedaço da tela:

function statsHtml() {
  const open = tickets.filter((t) => t.status !== 'done').length;
  return `
    <div class="stat"><strong>${tickets.length}</strong><span>total</span></div>
    <div class="stat"><strong>${open}</strong><span>not done</span></div>`;
}

O detalhe importante: a mesma função monta a página na primeira visita e todas as atualizações depois disso. A tela inicial e as atualizações ao vivo nunca divergem, porque são o mesmo código. Na maioria dos SPAs isso são dois sistemas diferentes (SSR de um lado, re-render no cliente do outro).

Recebe o que o usuário fez — muda o estado e manda só o que mudou:

case 'change-status': {
  const ticket = findTicket(msg.id);
  if (!ticket || !STATUSES.includes(msg.status)) return;
  ticket.status = msg.status;
  broadcast(ticketRowHtml(ticket).replace('<tr ', `<tr data-update-id="${ticket.id}" `)); // só a linha
  broadcast(`<div id="stats" ...>${statsHtml()}</div>`); // stats também mudam com o status
  break;
}

Aquela segunda linha do broadcast é todo o "gerenciamento de estado" do app: quando uma mudança afeta outro pedaço da tela, manda esse pedaço também. Simples de ler, simples de achar, sem mágica.

Manda para todo mundo — todos os clientes conectados recebem o mesmo HTML:

function broadcast(html) {
  for (const client of wss.clients) {
    if (client.readyState === client.OPEN) client.send(html);
  }
}

É por isso que a sincronização funciona: o usuário B muda um status, e a tela do usuário A se atualiza sozinha. Um escreve, todos recebem, ninguém precisa resolver conflito.

Os números

MétricaEste appApp típico em React
JS no navegador2,4 KB45–150 KB
Dependências1 (ws)centenas
Buildnão tembundler + config
Hidrataçãonão existecresce com o tamanho da tela
Primeiro conteúdo na telaimediatodepois de baixar e rodar o JS

Sem build, o projeto roda com um node server.js — sem webpack, sem config de vite, sem dor de cabeça com dependência quebrando.

Onde funciona e onde não funciona

Não vou fingir que serve para tudo:

Funciona bem:

  • Dashboards, painéis administrativos, monitoramento, chat, notificações, tabelas ao vivo — qualquer coisa em que o servidor tem a informação e a tela só mostra
  • Várias pessoas vendo a mesma coisa ao mesmo tempo (o ponto forte da demo)
  • Ferramentas internas, onde SEO não importa

Não funciona bem:

  • App que precisa funcionar offline — não tem cópia do estado no navegador
  • Coisas muito interativas (arrastar e soltar, canvas, editor rico)
  • Atualizações rápidas demais — chega uma hora em que manda mais HTML do que mandaria JSON
  • Otimistic UI refinada — toda interação vai até o servidor e volta

E um aviso: se duas pessoas editarem ao mesmo tempo, vale a última mudança. Para tickets, tudo bem. Para editar documento junto, esse problema tem outro nome (e outra solução).

Um detalhe: se você trocar uma tabela grande inteira, o foco e a posição do scroll dentro dela se perdem. Solução: troque pedaços pequenos.

A lição

O que mais me surpreendeu não foi o tamanho — foi o quanto de coisa o padrão elimina. O modelo mental cabe numa frase: o servidor sabe a verdade e manda a foto dela; o cliente cola a foto nova por cima da antiga.

Sem regra de hooks. Sem array de dependências. Sem bug de closure velha. Sem "por que esse effect rodou duas vezes?". A complexidade que sobrou foi toda para o produto.

Se a ideia fez sentido, conheça os frameworks que industrializam isso: htmx, Phoenix LiveView (Elixir), Hotwire/Turbo (Rails) e Datastar. Minha demo é a versão feita na mão de propósito, para dar para ver a mecânica funcionando.

Roda aí

O projeto inteiro tem umas 300 linhas em quatro arquivos:

git clone https://github.com/rogeriolaa/html-over-websockets
cd html-over-websockets
npm install && npm start     # http://localhost:3000

Abre em duas janelas lado a lado. Cria um ticket numa, muda o status na outra e vê as duas telas se atualizarem juntas.

Código-fonte: github.com/rogeriolaa/html-over-websockets

E você: usaria esse padrão no trabalho ou fica na stack SPA mesmo? Conta aqui nos comentários 👇

Carregando publicação patrocinada...