02Desempenho5 min

As listagens de admin são a primeira coisa que cai

O painel interno é escrito sem limites porque no começo há quatro linhas. É justamente o lugar onde tudo explode quando o produto cresce.

Por Salva Castells

Imagem de capa: Marijovanni, CC BY-SA 4.0.
Neste post

Um painel de administração começa sempre igual: uma tabela, quatro linhas de teste e um select sem where. Funciona. E como funciona, ninguém olha de novo. Enquanto isso a tabela de usuários passa de quatro linhas para quarenta mil, e a página interna que ninguém mede vira a mais lenta do produto.

Já tive que consertar isso em quatro projetos diferentes, e sempre pelos mesmos três motivos.

O admin é escrito sem cuidado porque ninguém o vê

A página pública tem paginação desde o primeiro dia. Alguém revisa, alguém mede, há uma métrica de Core Web Vitals que grita se demora. O admin não tem nada disso. Duas pessoas o usam, e essas duas pessoas assumem que ele é lento porque "é o admin".

Numa API de transcrição que mantenho, as server actions getAdminUsers e getAdminFeedback recebiam limit como parâmetro e o passavam direto para a consulta. Sem teto. Ninguém tinha mexido nisso havia meses porque, vindo da UI, chegava sempre um 10. O problema não é a UI: é que uma server action é um endpoint. Qualquer um com sessão de admin, ou qualquer script, podia pedir limit=1000000 e materializar a tabela inteira na memória do worker.

A correção foi de três linhas, numa leva de segurança:

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

O Math.max(limit, 1) não é paranoia gratuita: um limit negativo ou zero também faz coisas estranhas dependendo do driver. O importante é onde o teto mora. Não no componente, não no hook: na camada que recebe a requisição. Tudo o que está acima é sugestão.

O count que parece de graça e não é

A paginação clássica precisa saber quantas páginas existem, e para isso é preciso um SELECT COUNT(*). Você escreve sem pensar porque na cabeça da gente um count é barato. Não é: no Postgres um count exato percorre a tabela inteira ou o índice inteiro, e não tem jeito de não fazer isso.

Num gestor documental interno o admin de usuários fazia três coisas em paralelo a cada carregamento de página: trazer a página de perfis, trazer todas as empresas para o seletor, e contar o total de perfis. Com um pageSize de 5. Ou seja, percorria a tabela inteira para pintar "página 3 de 47".

Tirei o count e mudei o contrato da paginação:

setHasMore(profilesData.length === pageSize);

Perde-se o "de 47". Ganha-se que a consulta seja sempre proporcional ao tamanho da página. O botão de próxima agora depende de hasMore em vez de currentPage < totalPages, e o intervalo exibido é calculado com o que você já tem na mão, sem perguntar mais nada:

const endIndex = startIndex + companies.length - 1;

Ninguém reclamou de não ver o total. É um dado que num painel interno não decide absolutamente nada. Se você precisar mesmo, reltuples do pg_class te dá uma estimativa a custo zero; para "tem uns 40 mil usuários" já basta.

Trazer metadados em vez do objeto inteiro

O terceiro padrão é mais sutil. Uma lista de arquivos carrega bem até os arquivos terem conteúdo. Numa ferramenta de notas colaborativas a barra lateral chamava getProjectTextFiles, que fazia um select sem projeção: ou seja, trazia também a coluna content de cada arquivo do projeto. Para pintar uma lista de nomes.

A solução é chata e funciona: uma segunda função que seleciona explicitamente as colunas de que a lista precisa.

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

O conteúdo é pedido ao selecionar um arquivo, não antes. E aí aparece o detalhe que só se vê depois de ter feito: se o usuário clica rápido em dois arquivos, a resposta lenta do primeiro pode atropelar a do segundo. É preciso verificar, antes de escrever na store, que o arquivo continua sendo o selecionado:

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

Sem essa verificação você troca um problema de desempenho por um bug de corrida, que é pior porque não é reproduzível à vontade.

O bug que só aparece no painel de admin

Um dos melhores exemplos de por que o admin merece atenção me veio de um dashboard de analytics que cacheei com unstable_cache. Funcionava localmente e na primeira visita em produção. Depois caía com Invalid time value.

O unstable_cache serializa seu payload para JSON. Um Date escrito na execução fria volta como string em todas as leituras cacheadas. E Intl.DateTimeFormat.format não aceita uma string. O tipo mentia: lastIngestedAt: Date | null era na verdade Date | string | null. O TypeScript não conseguia ver porque a fronteira de serialização não é tipada.

Consertei normalizando as duas formas e tratando o impronunciável como ausente, não como exceção:

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

Um dado de data que não dá para pintar deve aparecer como "sem dados", não derrubar a página inteira. Num dashboard, uma métrica quebrada não pode levar junto as outras doze.

O que aplico hoje a qualquer listagem interna

  • Teto na camada de servidor. Um limit que vem de fora é limitado onde entra, com uma constante nomeada. A UI não é uma validação.
  • Sem count por padrão. Paginação por hasMore. Se alguém pedir o total, você dá uma estimativa, e só se pedir.
  • Projeção explícita. Uma lista seleciona colunas, nunca *. O blob é pedido ao abrir o detalhe.
  • Índice na coluna pela qual você filtra. Num app de registro de ponto havia índices compostos (user_id, date), mas as consultas que filtravam só por user_id não os aproveitavam bem; foi preciso adicionar o índice simples.

O painel de admin é o lugar para onde o time olha quando algo vai mal em produção. Que seja justamente a parte do sistema que não aguenta o volume de produção é uma ironia cara.