Div que se atualiza sozinha: polling com erro visível, sem empilhar requisições
Como atualizar um pedaço da página a cada tantos segundos sem mentir: estado de carregando e falha, horário da última atualização, pausa na aba oculta e nada de pedidos sobrepostos.
O painel da expedição mostra os pedidos prontos para envio e se atualiza a cada 10 segundos. Numa terça, o Wi-Fi do galpão caiu por 20 minutos. A lista continuou na tela, parada, com cara de atual. A equipe despachou com base num retrato velho e dois pedidos cancelados saíram no caminhão. A div não quebrou. Ela mentiu.
A resposta curta: uma div que se atualiza sozinha precisa dizer quando foi atualizada pela última vez e avisar quando falhou. Use fetch com setTimeout encadeado (não setInterval), um tempo limite por requisição, pausa quando a aba fica oculta, espera maior depois de erro e, se a vista tiver filtro, guarde o filtro na URL.
Por que a atualização automática mente
O tutorial clássico faz isto: setInterval chama um load() do jQuery a cada N segundos e troca o conteúdo da div. Funciona no dia da demonstração. Os problemas aparecem com rede ruim.
- Falha silenciosa: se a requisição falha, nada acontece. O conteúdo antigo fica, e não há diferença visual entre "atualizado agora" e "atualizado há 20 minutos".
- Requisições empilhadas:
setIntervaldispara no horário, não quando a anterior termina. Se o servidor demora 15 segundos e o intervalo é 10, as requisições se acumulam e as respostas podem chegar fora de ordem. A mais velha pode sobrescrever a mais nova. - Carga sem ninguém olhando: trinta abas esquecidas abertas no escritório fazem trinta consultas a cada 10 segundos para ninguém.
- Estado perdido: se a pessoa filtrou "só transportadora X" e apertou F5, o filtro some, porque ele vivia numa variável JavaScript, não no endereço.
Como montar o polling do jeito certo
- Faça o servidor entregar o conteúdo inicial já no HTML. A página tem que mostrar algo útil mesmo antes do primeiro ciclo de script.
- Agende a próxima atualização só depois que a atual terminar, com
setTimeout. - Dê a cada requisição um tempo limite com
AbortController. - Mostre na tela o horário da última atualização bem-sucedida.
- Em falha, mantenha os dados antigos, mas marque como desatualizados e aumente o intervalo.
- Pause quando
document.visibilityStateforhiddene atualize na hora quando a aba voltar.
O HTML do painel:
<section aria-labelledby="titulo-prontos">
<h2 id="titulo-prontos">Prontos para envio</h2>
<p id="status-painel" role="status">Atualizado às 14:02</p>
<div id="lista-prontos">
<!-- lista gerada pelo servidor na primeira carga -->
</div>
</section>
E o script, sem biblioteca:
const lista = document.getElementById('lista-prontos');
const status = document.getElementById('status-painel');
const INTERVALO_NORMAL = 10000;
const INTERVALO_MAXIMO = 120000;
let intervalo = INTERVALO_NORMAL;
let agendado = null;
let emAndamento = false;
let ultimaAtualizacao = status.textContent.replace('Atualizado às ', '');
function horaAgora() {
return new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
async function atualizar() {
if (emAndamento) return;
emAndamento = true;
const controle = new AbortController();
const limite = setTimeout(function () { controle.abort(); }, 8000);
try {
const params = new URLSearchParams(location.search);
const resposta = await fetch('/expedicao/prontos?' + params.toString(), {
signal: controle.signal,
headers: { 'Accept': 'text/html' }
});
if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
lista.innerHTML = await resposta.text();
ultimaAtualizacao = horaAgora();
status.textContent = 'Atualizado às ' + ultimaAtualizacao;
intervalo = INTERVALO_NORMAL;
} catch (erro) {
status.textContent = 'Falha ao atualizar. Os dados são de ' +
ultimaAtualizacao + ' e podem estar desatualizados.';
intervalo = Math.min(intervalo * 2, INTERVALO_MAXIMO);
} finally {
clearTimeout(limite);
emAndamento = false;
agendar();
}
}
function agendar() {
clearTimeout(agendado);
if (document.visibilityState === 'hidden') return;
agendado = setTimeout(atualizar, intervalo);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') atualizar();
else clearTimeout(agendado);
});
agendar();
Repare nos detalhes. A flag emAndamento impede duas requisições ao mesmo tempo, mesmo que a aba volte ao foco no meio de um ciclo. O setTimeout de 8 segundos aborta a requisição pendurada, e o erro cai no catch como qualquer outra falha. Depois de cada erro o intervalo dobra até 2 minutos, para não martelar um servidor que já está com problema.
O parágrafo com role="status" é anunciado por leitores de tela quando muda, sem roubar o foco. Se a cada 10 segundos o anúncio "Atualizado às" incomodar, mostre o horário em um elemento comum e reserve a região de status só para a mensagem de falha.
O conteúdo que entra na div
O exemplo usa innerHTML porque a resposta vem do seu próprio servidor, gerada a partir de um template que escapa os dados. Se a resposta for JSON, monte os elementos com textContent. Nunca injete HTML vindo de terceiro sem sanear: um nome de cliente com <img onerror> vira script rodando no painel de toda a equipe.
Filtro na URL: o estado que sobrevive ao F5
O painel tem um seletor de transportadora. Se a escolha só mudar uma variável, o F5 e o link enviado para o colega perdem o filtro. Grave o filtro na URL com history.replaceState e leia de lá a cada ciclo, como o script já faz com location.search:
document.getElementById('filtro-transportadora').addEventListener('change', function (e) {
const params = new URLSearchParams(location.search);
if (e.target.value) params.set('transportadora', e.target.value);
else params.delete('transportadora');
history.replaceState(null, '', '?' + params.toString());
atualizar();
});
Aqui replaceState basta, porque trocar o filtro de um painel não é navegação. Quando a troca é de verdade uma página nova, como abrir outra seção, use pushState e trate o popstate. Esse caso tem um guia próprio: trocar o miolo da página sem quebrar o botão voltar. Para formulários enviados sem recarregar, veja enviar formulário sem recarregar, sem perder o erro.
Quando polling não é a ferramenta
Polling a cada 10 segundos é simples e suficiente para painéis internos. Se a atualização precisa ser imediata, como chat ou leilão, ou se milhares de pessoas olham a mesma tela, considere Server-Sent Events ou WebSocket, em que o servidor avisa quando muda. Se o dado muda uma vez por hora, atualizar a cada 10 segundos é desperdício: aumente o intervalo ou ofereça um botão "atualizar".
Erros comuns
- Usar
setIntervalcom requisição lenta: os pedidos se sobrepõem e uma resposta antiga pode sobrescrever a mais nova. - Ignorar a falha: a tela continua mostrando dados velhos sem aviso, e alguém toma decisão com eles.
- Não ter tempo limite: uma requisição pendurada trava o ciclo inteiro, e o painel para de atualizar sem erro nenhum.
- Atualizar com a aba oculta: abas esquecidas geram carga constante no servidor para ninguém ver.
- Insistir no mesmo ritmo depois do erro: com o servidor já sobrecarregado, cada painel aberto piora a situação.
- Guardar o filtro só em variável: o F5 e o link compartilhado perdem o que a pessoa escolheu.
Checklist
- A primeira carga mostra o conteúdo sem depender do script.
- A próxima atualização só é agendada depois que a atual termina.
- Cada requisição tem tempo limite.
- A tela mostra o horário da última atualização bem-sucedida.
- Uma falha deixa aviso visível e aumenta o intervalo.
- A atualização pausa com a aba oculta e roda na hora quando ela volta.
- O filtro escolhido sobrevive a um F5.
Abra o seu painel, desligue a rede no DevTools por um minuto e olhe para a tela: alguém que chegasse agora saberia que aqueles dados estão parados?