Vinicius Aguiar
Frontend

De 6,7 MB para 19 KB: paginação cursor-based e virtualização em um inbox React

23 de jul de 2026 · 12 min de leitura

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.

Payload por requisição antes e depois: 6,7 MB contra 19 KB (~400x), com comparação em escala realPayload por requisição antes e depois: 6,7 MB contra 19 KB (~400x), com comparação em escala real

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 crescenteOFFSET 10000 obriga 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.

Diagrama de virtualização: das ~20.000 conversas, só as linhas dentro do viewport (mais o overscan) existem no DOMDiagrama de virtualização: das ~20.000 conversas, só as linhas dentro do viewport (mais o overscan) existem no DOM

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-posinset para 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.