Aprenda a construir listas de dashboard rápidas com 100k linhas usando paginação, virtualização, filtros inteligentes e consultas melhores para que ferramentas internas permaneçam rápidas.

Uma tela de lista geralmente parece ok até que deixa de ser. Usuários começam a notar pequenas travadas que se acumulam: rolagem com gagueira, a página fica presa por um instante após cada atualização, filtros demoram segundos para responder e aparece um spinner a cada clique. Às vezes a aba do navegador parece congelada porque a thread de UI está ocupada.
100k linhas é um ponto de virada comum porque estressa todas as partes do sistema ao mesmo tempo. O conjunto de dados ainda é normal para um banco, mas já é grande o suficiente para tornar ineficiências pequenas óbvias no navegador e na rede. Se você tentar mostrar tudo de uma vez, uma tela simples vira um pipeline pesado.
O objetivo não é renderizar todas as linhas. O objetivo é ajudar alguém a encontrar o que precisa rápido: as 50 linhas certas, a próxima página, ou um recorte estreito baseado em um filtro.
Ajuda dividir o trabalho em quatro partes:
Se qualquer uma dessas partes for cara, a tela inteira fica lenta. Uma busca simples pode disparar uma requisição que ordena 100k linhas, retorna milhares de registros e então força o navegador a renderizá-los todos. É assim que digitar fica travado.
Quando equipes constroem ferramentas internas rapidamente (inclusive com plataformas estilo vibe-coding como Koder.ai), telas de lista costumam ser o primeiro lugar onde o crescimento real de dados expõe a diferença entre "funciona no conjunto de demo" e "parece instantâneo todo dia".
Antes de otimizar, decida o que significa ser rápido nessa tela. Muitas equipes correm atrás de throughput (carregar tudo) quando os usuários precisam principalmente de baixa latência (ver algo atualizar rápido). Uma lista pode parecer instantânea mesmo que nunca carregue as 100k linhas, desde que responda rápido ao rolar, ordenar e filtrar.
Uma meta prática é tempo até a primeira linha, não tempo até carregar tudo. Usuários confiam na página quando veem as primeiras 20 a 50 linhas rapidamente e as interações continuam suaves.
Escolha um pequeno conjunto de números que você possa acompanhar sempre que mudar algo:
COUNT(*) e SELECTs amplas)Esses pontos se mapeiam para sintomas comuns. Se a CPU do navegador sobe quando você rola, o frontend está fazendo muito trabalho por linha. Se o spinner espera mas a rolagem fica bem depois, o problema normalmente é backend ou rede. Se a requisição é rápida mas a página ainda congela, quase sempre é renderização ou processamento pesado no cliente.
Tente um experimento simples: mantenha a UI igual, mas limite temporariamente o backend para retornar apenas 20 linhas com os mesmos filtros. Se ficar rápido, seu gargalo é tamanho de carga ou tempo de consulta. Se continuar lento, olhe para renderização, formatação e componentes por linha.
Exemplo: uma tela interna de Orders parece lenta quando você digita na busca. Se a API retorna 5.000 linhas e o navegador as filtra a cada pressionar de tecla, digitar vai travar. Se a API demora 2 segundos por causa de um COUNT em um filtro sem índice, você vai ver espera antes de qualquer linha mudar. Fixes diferentes, mesma reclamação do usuário.
O navegador costuma ser o primeiro gargalo. Uma lista pode parecer lenta mesmo quando a API é rápida, simplesmente porque a página está tentando pintar demais. A primeira regra é simples: não renderize milhares de linhas no DOM de uma vez.
Mesmo antes de adicionar virtualização completa, mantenha cada linha leve. Uma linha com wrappers aninhados, ícones, tooltips e estilos condicionais complexos em cada célula custa caro a cada rolagem e atualização. Prefira texto simples, alguns badges pequenos e apenas um ou dois elementos interativos por linha.
Altura de linha estável ajuda mais do que parece. Quando cada linha tem a mesma altura, o navegador pode prever o layout e a rolagem fica suave. Linhas de altura variável (descrições que quebram linha, notas expansíveis, avatares grandes) disparam medições extras e reflows. Se precisar de detalhes extras, considere um painel lateral ou uma área expansível única, não uma linha multi-linha completa.
Formatação é outro custo silencioso. Datas, moedas e manipulação pesada de strings somam quando repetidos em muitas células.
Se um valor não está visível, não o calcule ainda. Faça cache de formatações caras e calcule sob demanda, por exemplo quando uma linha fica visível ou quando o usuário abre uma linha.
Um conjunto de ações rápidas que frequentemente traz ganhos notáveis:
Exemplo: uma tabela interna de Invoices que formata 12 colunas de moedas e datas vai gaguejar na rolagem. Cachear os valores formatados por invoice e adiar trabalho para linhas fora de tela pode deixá-la instantânea, mesmo antes de trabalhar o backend profundamente.
Virtualização significa que a tabela só desenha as linhas que você realmente pode ver (mais um pequeno buffer acima e abaixo). Conforme você rola, ela reaproveita os mesmos elementos DOM e troca os dados dentro deles. Isso impede que o navegador tente pintar dezenas de milhares de componentes de linha de uma vez.
A virtualização é um bom ajuste quando você tem listas longas, tabelas largas ou linhas pesadas (avatares, chips de status, menus de ação, tooltips). Também é útil quando usuários rolam bastante e esperam uma vista contínua e suave em vez de pular página a página.
Não é mágica. Algumas coisas costumam causar surpresas:
A abordagem mais simples é chata: altura fixa por linha, colunas previsíveis e não muitas widgets interativas dentro de cada linha.
Você pode combinar ambos: use paginação (ou carregar mais com cursor) para limitar o que busca no servidor, e virtualização para manter a renderização barata dentro do slice buscado.
Um padrão prático é buscar um tamanho de página normal (frequentemente 100 a 500 linhas), virtualizar dentro dessa página e oferecer controles claros para navegar entre páginas. Se usar rolagem infinita, adicione um indicador visível “Carregado X de Y” para que os usuários entendam que ainda não estão vendo tudo.
Se você precisa de uma lista que permaneça utilizável conforme os dados crescem, paginação costuma ser o padrão mais seguro. É previsível, funciona bem para fluxos administrativos (revisar, editar, aprovar) e suporta necessidades comuns como exportar “página 3 com estes filtros” sem surpresas. Muitas equipes voltam para paginação depois de tentar rolagens mais fancy.
Rolagem infinita pode ser agradável para navegação casual, mas tem custos ocultos. Pessoas perdem a noção de posição, o botão voltar frequentemente não retorna ao mesmo ponto e sessões longas podem acumular memória conforme mais linhas são carregadas. Um meio-termo é um botão Carregar mais que ainda usa páginas, assim os usuários se mantêm orientados.
Paginação por offset é o clássico page=10&size=50. É simples, mas pode ficar mais lenta em tabelas grandes porque o banco pode ter que pular muitas linhas para alcançar páginas finais. Também pode parecer estranho quando novas linhas chegam e itens mudam de página.
Paginação por keyset (frequentemente chamada de cursor) pede as “próximas 50 linhas após o último item visto”, geralmente usando um id ou created_at. Ela tende a ficar rápida porque não precisa contar e pular tanto trabalho.
Uma regra prática:
Usuários gostam de ver totais, mas um “count all matching rows” completo pode ser caro com filtros pesados. Opções incluem cachear contagens para filtros populares, atualizar a contagem em segundo plano depois que a página carrega ou mostrar uma contagem aproximada (por exemplo, “10.000+”).
Exemplo: uma tela interna de Orders pode mostrar resultados instantaneamente com paginação por keyset, e preencher o total exato apenas quando o usuário para de mudar filtros por um segundo.
Se você está construindo isso no Koder.ai, trate comportamento de paginação e contagem como parte da especificação da tela desde cedo, para que as queries geradas e o estado da UI não entrem em conflito depois.
A maioria das telas de lista fica lenta porque começa muito aberta: carrega tudo e depois pede para o usuário filtrar. Inverta isso. Comece com padrões sensatos que retornem um conjunto pequeno e útil (por exemplo: últimos 7 dias, Meus itens, Status: Aberto) e torne “Todo o período” uma escolha explícita.
Busca por texto é outra armadilha comum. Se você executa uma query a cada tecla, você cria um backlog de requisições e uma UI que pisca. Faça debounce na entrada de busca para só consultar depois que o usuário fizer uma pausa breve e cancele requisições antigas quando uma nova começar. Uma regra simples: se o usuário ainda está digitando, não bata no servidor ainda.
Filtrar só parece rápido quando também é claro. Mostre chips de filtro perto do topo da tabela para que os usuários vejam o que está ativo e possam remover com um clique. Mantenha os rótulos dos chips humanos, não nomes crus de campos (por exemplo, Dono: Sam em vez de owner_id=42). Quando alguém diz “meus resultados desapareceram”, geralmente é um filtro invisível.
Padrões que mantêm listas grandes responsivas sem complicar a UI:
Visualizações salvas são o herói discreto. Em vez de ensinar usuários a montar combos de filtros uma vez, dê alguns presets que batem com fluxos reais. Uma equipe de ops pode alternar entre Pagamentos falhados hoje e Clientes de alto valor. Esses podem ser um clique, instantâneos e mais fáceis de manter rápidos no backend.
Se você construir uma ferramenta interna em um construtor guiado por chat como o Koder.ai, trate filtros como parte do fluxo do produto, não um acréscimo. Comece pelas perguntas mais comuns e desenhe a visão padrão e as visualizações salvas ao redor disso.
Uma tela de lista raramente precisa dos mesmos dados que uma página de detalhe. Se sua API retorna tudo sobre tudo, você paga duas vezes: o banco faz mais trabalho e o navegador recebe e renderiza mais do que usa. Query shaping é o hábito de pedir apenas o que a lista precisa agora.
Comece retornando apenas as colunas necessárias para renderizar cada linha. Para a maioria dos dashboards isso é um id, alguns rótulos, um status, um responsável e timestamps. Texto grande, blobs JSON e campos computados podem esperar até o usuário abrir a linha.
Evite joins pesados para a primeira pintura. Joins são ok quando batem em índices e retornam resultados pequenos, mas ficam caros quando você junta várias tabelas e então ordena ou filtra pelos dados unidos. Um padrão simples é: busque a lista de uma tabela rapidamente e depois carregue detalhes relacionados sob demanda (ou em lote para as linhas visíveis apenas).
Mantenha opções de ordenação limitadas e ordene por colunas indexadas. “Ordenar por qualquer coisa” soa útil, mas frequentemente força ordenações lentas em datasets grandes. Prefira algumas escolhas previsíveis como created_at, updated_at ou status e garanta que essas colunas tenham índice.
Cuidado com agregações no servidor. COUNT(*) em um conjunto filtrado grande, DISTINCT em uma coluna ampla ou cálculos de total de páginas podem dominar seu tempo de resposta.
Uma abordagem prática:
COUNT e DISTINCT como opcionais e cache/approxime quando possívelSe você construir ferramentas internas no Koder.ai, defina uma query leve para listas separada da query de detalhes no planejamento, assim a UI permanece rápida conforme os dados crescem.
Se você quer uma tela de lista que permaneça rápida em 100k linhas, o banco precisa fazer menos trabalho por requisição. A maioria das listas lentas não é “dados demais.” São padrões de acesso errado.
Comece com índices que correspondam ao que seus usuários realmente fazem. Se sua lista costuma ser filtrada por status e ordenada por created_at, você quer um índice que dê suporte a ambos, nessa ordem. Caso contrário o banco pode escanear muito mais linhas do que você espera e depois ordenar, o que fica caro rápido.
Correções que geralmente trazem os maiores ganhos:
tenant_id, status, created_at).OFFSET profundo. OFFSET faz o banco caminhar por muitas linhas só para pulá-las.Exemplo simples: uma tabela interna Orders que mostra nome do cliente, status, valor e data. Não junte todas as tabelas relacionadas e puxe notas completas do pedido para a vista de lista. Retorne apenas as colunas usadas na tabela e carregue o resto em uma requisição separada quando o usuário clicar no pedido.
Se você está construindo com uma plataforma como Koder.ai, mantenha essa mentalidade mesmo se a UI for gerada por chat. Garanta que os endpoints gerados suportem paginação por cursor e campos seletivos, para que o trabalho do banco continue previsível conforme a tabela cresce.
Se uma página de lista está lenta hoje, não comece reescrevendo tudo. Comece definindo qual é o uso normal e então otimize esse caminho.
Defina a visão padrão. Escolha filtros padrão, ordem e colunas visíveis. Listas ficam lentas quando tentam mostrar tudo por padrão.
Escolha um estilo de paginação que combine com o uso. Se usuários olham principalmente as primeiras páginas, paginação clássica é suficiente. Se pulam muito (página 200+) ou você precisa de desempenho estável independente da profundidade, use paginação por keyset (baseada em uma ordenação estável como created_at mais um id).
Adicione virtualização ao corpo da tabela. Mesmo se o backend for rápido, o navegador pode travar ao renderizar muitas linhas de uma vez.
Faça busca e filtros parecerem instantâneos. Debounce na digitação para não disparar requisição a cada tecla. Mantenha o estado de filtro na URL ou em um store compartilhado para que refresh, botão voltar e compartilhamento funcionem bem. Cacheie o último resultado bem-sucedido para evitar flash de tabela vazia.
Meça, então ajuste consultas e índices. Logue tempo do servidor, tempo no banco, tamanho do payload e tempo de render. Depois aparar a query: selecione apenas as colunas que mostra, aplique filtros cedo e adicione índices que combinem com o filtro + ordenação padrão.
Exemplo: um dashboard de suporte interno com 100k tickets. Padrão para Aberto, atribuído ao meu time, ordenado por mais novo, mostrar seis colunas e buscar só ticket id, subject, assignee, status e timestamps. Com paginação por keyset e virtualização, você mantém banco e UI previsíveis.
Se construir no Koder.ai, esse plano se encaixa bem em um fluxo iterativo: ajuste a visão, teste rolagem e busca, depois afine a query até a página permanecer snappy.
A maneira mais rápida de quebrar uma tela de lista é tratar 100k linhas como uma página normal de dados. A maioria das dashboards lentas tem algumas armadilhas previsíveis.
Uma grande é renderizar tudo e esconder com CSS. Mesmo que pareça que só 50 linhas estão visíveis, o navegador ainda paga por criar 100k nós do DOM, medi-los e repintar na rolagem. Se precisa de listas longas, renderize só o que o usuário pode ver (virtualização) e mantenha componentes de linha simples.
Busca também pode arruinar performance quando cada tecla dispara um scan completo da tabela. Isso acontece quando filtros não têm índice, quando você busca em muitas colunas ou quando faz queries de contains em campos de texto enormes sem um plano. Uma boa regra: o primeiro filtro que o usuário tende a usar deve ser barato no banco, não apenas conveniente na UI.
Outro problema comum é buscar registros completos quando a lista só precisa de resumos. Uma linha de lista normalmente precisa de 5 a 12 campos, não do objeto inteiro, não de descrições longas e não de dados relacionados. Puxar dados extras aumenta trabalho no banco, tempo de rede e parsing no frontend.
Exportar e calcular totais pode travar a UI se o trabalho for feito na thread principal ou se você esperar por uma requisição pesada antes de responder. Mantenha a UI interativa: inicie exports em background, mostre progresso e evite recalcular totais a cada mudança de filtro.
Por fim, muitas opções de ordenação podem atrapalhar. Se usuários podem ordenar por qualquer coluna, você vai acabar ordenando grandes conjuntos em memória ou forçando o banco a planos lentos. Limite ordenações a um pequeno conjunto de colunas indexadas e faça a ordenação padrão casar com um índice real.
Checagem rápida:
Trate a performance de listas como um recurso de produto, não um ajuste pontual. Uma tela de lista é rápida só quando parece rápida enquanto pessoas reais rolam, filtram e ordenam com dados reais.
Use este checklist para confirmar que corrigiu as coisas certas:
Um cheque simples: abra a lista, role por 10 segundos e depois aplique um filtro comum (por exemplo Status: Aberto). Se a UI congelar, o problema geralmente é renderização (linhas DOM demais) ou uma transformação pesada no cliente (ordenar, agrupar, formatar) acontecendo a cada atualização.
Próximos passos, na ordem, para não ficar pulando entre consertos:
Se você construir isso com Koder.ai (koder.ai), comece no Planning Mode: defina exatamente as colunas da lista, campos de filtro e o formato da resposta primeiro. Depois itere usando snapshots e rollback quando um experimento deixar a tela mais lenta.
Mude o objetivo de “carregar tudo” para “mostrar as primeiras linhas úteis rapidamente.” Otimize para tempo até a primeira linha e para interações suaves ao filtrar, ordenar e rolar, mesmo que o conjunto completo de dados nunca seja carregado de uma vez.
Meça o tempo até a primeira linha após carregar ou mudar um filtro, o tempo para filtro/ordenar atualizar, o tamanho do payload de resposta, consultas lentas no banco (especialmente SELECTs amplas e COUNT(*)) e picos na thread principal do navegador. Esses números correspondem diretamente ao que os usuários percebem como “lag”.
Limite temporariamente a API para devolver apenas 20 linhas com os mesmos filtros e ordenação. Se ficar rápido, o custo principal é a consulta ou o tamanho do payload; se continuar lento, o gargalo costuma ser renderização, formatação ou trabalho no cliente por linha.
Não renderize milhares de linhas no DOM ao mesmo tempo, mantenha os componentes de linha simples e prefira altura fixa por linha. Evite fazer formatações caras para linhas fora de tela; compute e cache formatações apenas quando a linha ficar visível ou for aberta.
A virtualização mantém montadas apenas as linhas visíveis (mais um pequeno buffer), reaproveitando elementos DOM conforme você rola. Vale a pena quando os usuários rolam muito ou as linhas são “pesadas”, mas funciona melhor com altura de linha consistente e layout previsível.
Para a maioria dos fluxos administrativos, paginação é o padrão mais seguro: mantém o usuário orientado e limita o trabalho do servidor. Rolagem infinita pode funcionar para navegação casual, mas costuma complicar navegação e uso de memória, a menos que você imponha limites e estado claro.
Paginação por offset é simples (page=10&size=50) mas pode ficar lenta em páginas profundas porque o banco precisa pular muitas linhas. Paginação por keyset (cursor) continua rápido porque parte do último registro visto (created_at ou id), mas não é ideal para pular a uma página numérica exata.
Não faça requisição a cada tecla. Debounce na entrada de texto, cancele requisições em andamento quando uma nova começar, e prefira filtros iniciais restritos (datas recentes, “meus itens”) para que a primeira consulta seja pequena e útil.
Retorne apenas os campos que a lista realmente renderiza — normalmente um pequeno conjunto como id, rótulo, status, responsável e timestamps. Deixe textos grandes, blobs JSON e dados relacionados para uma requisição de detalhe, mantendo a primeira pintura leve e previsível.
Faça o filtro e a ordenação padrão refletirem o uso real e adicione índices que suportem esse padrão, frequentemente um índice composto que combine filtros com a coluna de ordenação. Trate totais exatos como opcionais: precompute, cache ou mostre um valor aproximado para não bloquear a resposta principal.