Todo SaaS B2B tem uma tela onde o produto inteiro acontece. No caso deste post — uma plataforma de mensagens usada por times comerciais — essa tela é o inbox: a lista de conversas que os usuários mantêm aberta o dia todo. Em workspaces maduros, essa lista acumula ~20.000 conversas. E a versão original carregava todas elas de uma vez.
Este post documenta a reconstrução dessa tela do ponto de vista de frontend: por que paginação offset-based não resolvia, como ficou o contrato de API cursor-based, a implementação com React Query e TanStack Virtual, e os trade-offs assumidos. O resultado: o payload caiu de ~6,7 MB para ~19 KB por requisição (~400x), e os travamentos de interface desapareceram.
O problema: carregar tudo de uma vez
O sintoma era visível a olho nu: abrir o inbox em um workspace grande levava vários segundos, e a interface travava durante a renderização. O DevTools contava o resto da história: uma única resposta JSON de ~6,7 MB, e o Performance profiler mostrando long tasks bloqueando a main thread enquanto o React montava milhares de linhas.
A causa raiz era um endpoint sem paginação: a listagem retornava todas as conversas do workspace, e o cliente renderizava a lista inteira. Esse desenho tem uma propriedade traiçoeira: piora silenciosamente com o sucesso do produto. Com 500 conversas, ninguém percebe. Com 20.000, a tela mais importante do produto vira a mais lenta.
Havia dois custos distintos misturados, e vale separá-los porque as soluções são diferentes: o custo de rede e serialização (baixar e parsear 6,7 MB) e o custo de renderização (manter ~20.000 componentes montados no DOM). Paginação resolve o primeiro. Só virtualização resolve o segundo.
Por que não offset-based
A primeira ideia óbvia seria ?page=3&limit=50. Para um inbox, paginação offset-based falha em dois pontos:
- Custo de leitura crescente —
OFFSET 10000obriga o banco a percorrer e descartar 10.000 linhas antes de devolver as 50 seguintes. As páginas ficam mais lentas quanto mais fundo o usuário navega. - Instabilidade sob escrita concorrente — um inbox recebe mensagens novas o tempo todo, e cada inserção desloca o offset: itens aparecem duplicados ou somem entre uma página e a seguinte. É exatamente o tipo de bug intermitente que ninguém consegue reproduzir.
Paginação cursor-based (keyset) resolve os dois: em vez de contar linhas, cada página parte de um ponteiro estável para o último registro carregado. O custo de leitura fica constante e inserções novas não deslocam nada.
O contrato da API
O cursor é opaco para o cliente: uma string codificada que só o backend sabe interpretar — na prática, o par ordenável (updated_at, id), com o id desempatando registros de mesmo timestamp. O contrato que o frontend consome:
// GET /conversations?workspaceId=...&cursor=...&limit=50
type ConversationsPage = {
items: ConversationPreview[]
nextCursor: string | null // null => last page
}
// The list never receives the full conversation:
// only the fields the row actually renders
type ConversationPreview = {
id: string
contactName: string
lastMessageSnippet: string
lastMessageAt: string
unreadCount: number
status: "open" | "closed" | "snoozed"
}Um detalhe com impacto desproporcional: a lista não recebe a conversa completa, só os campos que a linha exibe. O tipo ConversationPreview existe para isso — impedir que o payload da listagem cresça junto com o modelo completo de conversa. Payload shaping e paginação, combinados, é que produzem os ~19 KB por página.
Data fetching com React Query
O useInfiniteQuery foi feito exatamente para esse formato: cada página declara de onde vem a próxima via getNextPageParam, e a biblioteca cuida de cache, deduplicação e estados de carregamento:
function useConversations(workspaceId: string) {
return useInfiniteQuery({
queryKey: ["conversations", workspaceId],
queryFn: ({ pageParam }) =>
fetchConversations({ workspaceId, cursor: pageParam, limit: 50 }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}
// Pages arrive as an array of arrays;
// the list consumes a single flat array
const allRows = data ? data.pages.flatMap((page) => page.items) : []O ponto central desse desenho é que o acúmulo acontece no cache, não no DOM: navegar fundo no inbox adiciona páginas ao React Query, mas — como a próxima seção mostra — o número de elementos renderizados continua o mesmo.
Virtualização com TanStack Virtual
Paginação sozinha reduz o payload, não o custo de renderização: se o usuário rolar o suficiente, as páginas acumuladas remontam o problema original, linha a linha, dentro do DOM. A resposta é windowing — renderizar apenas as linhas visíveis no viewport (mais uma folga de overscan), posicionadas de forma absoluta dentro de um contêiner que mantém a altura total da lista, o que preserva a barra de rolagem honesta.
Com @tanstack/react-virtual, o useVirtualizer vira também o gatilho natural da paginação: quando o último item virtual se aproxima do fim da lista carregada, pede-se a próxima página:
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88, // row height estimate; measureElement refines it
overscan: 8,
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const last = virtualItems.at(-1)
if (!last) return
if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div ref={parentRef} className="h-full overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualItems.map((row) => (
<ConversationRow
key={allRows[row.index]?.id ?? "loader"}
ref={virtualizer.measureElement}
data-index={row.index}
conversation={allRows[row.index]}
style={{
position: "absolute",
top: 0,
transform: `translateY(${row.start}px)`,
}}
/>
))}
</div>
</div>
)Duas decisões nesse trecho merecem comentário. O count inclui uma linha extra quando há próxima página — ela vira o esqueleto de carregamento no fim da lista. E o measureElement mede a altura real de cada linha depois de montada, porque linhas de inbox não têm altura fixa (snippets de uma ou duas linhas, badges, estados). O estimateSize só precisa ser bom o bastante para a primeira pintura.
Resultados
- Payload por requisição: de ~6,7 MB para ~19 KB (~400x)
- Fim dos travamentos de interface — com um DOM estável de ~10–15 linhas, as long tasks de renderização sumiram do profiler
- A abertura do inbox deixou de levar vários segundos, mesmo nos maiores workspaces
- O custo da tela parou de crescer com o tamanho do workspace: 500 ou 20.000 conversas, o mesmo trabalho por frame
Trade-offs assumidos
Nenhuma dessas escolhas é gratuita, e vale registrar o que se perde:
- Sem salto para página arbitrária — cursor não sabe responder "me leve à página 37". Para um inbox, navegado como um fluxo cronológico com busca e filtros por cima, isso não fez falta; para uma tabela administrativa, seria um problema real.
- Virtualização adiciona complexidade genuína — medição de alturas dinâmicas, restauração de scroll, overscan calibrado. É custo de engenharia que só se paga quando a lista é grande de verdade.
- O que não está no DOM não existe para o navegador — o Cmd+F da página não encontra linhas não renderizadas, e leitores de tela precisam de atenção extra (
aria-setsize/aria-posinsetpara as contagens). A busca do produto passa a ser a busca de verdade.
Quando não usar nada disso
Se a sua lista tem algumas centenas de itens, pare no payload shaping e numa paginação simples — virtualização é resposta para milhares de linhas, não um padrão default. A régua que passei a usar: paginação cursor-based quando a lista cresce sem teto junto com o uso do produto; virtualização quando uma única lista ameaça passar de ~1.000 nós no DOM.
O que merece atenção numa segunda passada
Dois pontos que valem cuidado em qualquer implementação dessa arquitetura: restauração de scroll entre navegações — voltar de uma conversa para a lista deve devolver o usuário exatamente onde estava, e isso exige persistir o offset fora do componente, já que o virtualizador não sobrevive ao unmount — e invalidação cirúrgica em tempo real: mensagens novas chegando via WebSocket devem atualizar a página certa do cache com setQueryData, não invalidar a query inteira e refazer o trabalho que a paginação economizou.
A lição que fica é menos sobre bibliotecas e mais sobre separar custos: rede, serialização e renderização degradam por mecanismos diferentes e se resolvem com ferramentas diferentes. Paginação cursor-based cuida do que trafega; virtualização cuida do que renderiza. Juntas, transformaram a tela mais pesada do produto na mais estável — e o custo dela parou de acompanhar o crescimento dos clientes.
