Análise, renderização e streaming de JSON grande
Resumo: A análise muitas vezes não é o gargalo; a renderização e a cópia são. Otimize ao longo das quatro camadas "analisar → percorrer → renderizar → transportar" para não desperdiçar esforço.

Resumo da ópera: meça análise, travessia, renderização e transporte separadamente primeiro, depois trabalhe nesta ordem — corte cópias, expanda sob demanda com rolagem virtual, mova o trabalho para um Worker, mude para streaming e só então mude o formato. Streaming só compensa quando o arquivo não cabe na memória ou quando você precisa de um pequeno subconjunto dos seus campos.
A conclusão "JSON.parse é lento" está errada oito em cada dez vezes. O que é realmente lento é o que você faz depois de analisar.
1. Meça primeiro, depois otimize
Para o mesmo arquivo JSON de 10MB, o tempo gasto em etapas diferentes difere por ordens de magnitude:
| Etapa | Duração típica | Custo principal |
|---|---|---|
JSON.parse (nativo) |
Dezenas a pouco mais de cem milissegundos | Uma única varredura linear mais construção de objetos |
Cópia profunda (structuredClone / recursiva) |
Possivelmente 1–3 vezes a análise | Reconstruir o grafo de objetos inteiro |
| Comparação profunda / diff | Possivelmente o dobro de novo | Travessia recursiva mais comparação de strings |
| Renderização em uma árvore DOM | Congela quando há dezenas de milhares de nós | Layout e reflow |
| Concatenação de strings e depois serialização | Proporcional ao tamanho da saída | Dobra a memória |
Então meça antes de agir: no navegador, envolva cada etapa com performance.now(), ou use console.time;
no Node, use process.memoryUsage() para observar o crescimento do heap. Sem linha de base, sem otimização.
2. Camada de análise: três estratégias
2.1 Análise completa (a escolha padrão)
const data = JSON.parse(text);
A implementação nativa faz a varredura e a construção de objetos em C++, então uma biblioteca puramente em JS dificilmente a supera. Use-a enquanto as três condições a seguir valerem:
- O texto cabe inteiramente na memória (normalmente < 100MB);
- A maioria dos campos é realmente necessária após a análise;
- O grau de desconfiança dos dados é administrável.
2.2 Análise incremental em streaming
Com um analisador no estilo SAX como stream-json, você consegue varrer os campos alvo sem construir uma árvore de objetos completa:
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
// Processa um elemento por vez; a memória independe da contagem de elementos
handleItem(value);
}
Custos:
- A complexidade do código aumenta significativamente e o tratamento de erros fica mais confuso;
- Só é possível acesso sequencial, sem acesso aleatório;
- É pouco amigável a requisitos que precisam de agregação global.
Regra de decisão: streaming só compensa quando o arquivo é grande demais para caber, ou quando você precisa apenas de um subconjunto muito pequeno dos seus campos.
2.3 Mude o formato
Se os dados são "um lote de registros" em vez de "um único documento", você deveria ter usado JSON Lines desde o início. Um formato delimitado por linha transforma "streaming" em "ler linha a linha", reduzindo a complexidade de implementação em uma ordem de magnitude.
3. Camada de travessia: uma cópia a menos ganha metade da batalha
// Antipadrão: copiar profundamente primeiro "por segurança"
const safeCopy = structuredClone(bigData); // A memória dobra imediatamente
// Bom padrão: passe referências diretamente em cenários somente leitura
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
Cópias ocultas comuns:
JSON.parse(JSON.stringify(x))usado como "cópia profunda" — este é o tipo mais caro;{ ...data }copia apenas um nível, mas para um objeto grande ainda copia o array de referências de todas as chaves de nível superior;Array.prototype.mapcria um novo array, o que também é caro quando há muitos elementos;- Proxies de reatividade de frameworks (o
reactivedo Vue) fazem proxy de objetos grandes recursivamente, e o overhead pode ser maior que a própria análise.
Conselho prático: mantenha os "dados crus" como um objeto simples imutável e não os envolva em reatividade; calcule e faça cache separadamente dos dados derivados que você precisa exibir.
4. Camada de renderização: não enfie a árvore inteira no DOM
Um arquivo JSON de 5MB tem aproximadamente 30.000 a 100.000 nós; renderizá-los todos como elementos DOM fará o navegador parar de responder.
Três abordagens viáveis:
4.1 Expandir sob demanda
Renderize apenas os filhos diretos do nó raiz e calcule o próximo nível apenas quando o nó for expandido. Esta é a abordagem padrão para visualizadores de árvore:
type TreeNode = { key: string; value: unknown; expanded: boolean };
/**
* Retorna apenas os nós visíveis no momento, evitando construir a árvore inteira de uma vez.
* @param rootNode Nó raiz
* @param expandedPaths Conjunto de caminhos que foram expandidos
* @returns Lista de nós visíveis
*/
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
const visibleNodes: TreeNode[] = [];
/**
* Coleta recursivamente os nós filhos dos ramos expandidos.
* @param currentNode Nó atual
* @param currentPath Caminho atual
*/
const walk = (currentNode: TreeNode, currentPath: string): void => {
visibleNodes.push(currentNode);
if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
return;
}
for (const [childKey, childValue] of Object.entries(currentNode.value)) {
walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
}
};
walk(rootNode, '');
return visibleNodes;
}
4.2 Rolagem virtual
Quando os nós expandidos passam de algumas centenas, renderize apenas as poucas dezenas dentro do viewport e reserve espaço para o resto com alturas de placeholder. É mais eficaz em visões no estilo de lista (tabelas, logs).
4.3 Mostre apenas resumos
Apresente primeiro "estrutura + tipo + contagem" (por exemplo items: Array(12000)); só quando o usuário abre um item você busca o valor concreto.
Esse truque funciona melhor para o cenário "só quero confirmar se os campos estão certos".
5. Thread principal: mova o trabalho para um Worker quando for o caso
Analisar, formatar e comparar qualquer coisa maior que 1MB deve ir para um Web Worker:
// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
try {
const parsed = JSON.parse(event.data.text);
self.postMessage({ ok: true, data: parsed });
} catch (parseError) {
self.postMessage({ ok: false, message: (parseError as Error).message });
}
};
/**
* Analisa JSON dentro de um Worker para que a thread principal não seja bloqueada.
* @param text Texto JSON
* @returns Resultado da análise
*/
function parseInWorker(text: string): Promise<unknown> {
return new Promise((resolvePromise, rejectPromise) => {
const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event) => {
worker.terminate();
if (event.data.ok) {
resolvePromise(event.data.data);
} else {
rejectPromise(new Error(event.data.message));
}
};
worker.postMessage({ text });
});
}
Cuidado com o custo de comunicação dos Workers: postMessage usa clonagem estruturada por padrão, e clonar um objeto de 10MB uma vez também leva dezenas de milissegundos.
Se tudo o que você precisa é "analisar e entregar à thread principal", faça o trabalho pesado dentro do Worker (analisar + transformar + validar) e envie de volta um resumo do resultado.
6. Camada de transporte: otimização de tamanho
| Técnica | Ganho típico | Custo |
|---|---|---|
| gzip / brotli | A saída comprimida costuma ser 10%–25% do tamanho original | Requer suporte do servidor |
| Remover indentação e espaços em branco | Redução de 20%–40% | Não é legível por humanos |
| Abreviar nomes de campo | Ganhos perceptíveis com aninhamento profundo | Menor legibilidade; requer tabela de mapeamento |
| Números em vez de strings de enum | Pequeno | Requer um dicionário |
| Paginação / campos sob demanda | O maior | Redesenho da API |
Para páginas voltadas a humanos, a combinação correta é: a API envia JSON compacto e comprimido, e a interface o formata apenas para exibição. Muitos sites de ferramentas por padrão enviam texto formatado de volta ao frontend, enviando 30% mais bytes à toa.
7. Como fazer benchmark
/**
* Mede a duração mediana de uma rotina síncrona para que uma única execução instável não distorça o resultado.
* @param runOnce Função sob teste
* @param repeatCount Número de repetições
* @returns Duração mediana em milissegundos
*/
function measureMedian(runOnce: () => void, repeatCount = 9): number {
const durations: number[] = [];
for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
const startedAt = performance.now();
runOnce();
durations.push(performance.now() - startedAt);
}
durations.sort((firstValue, secondValue) => firstValue - secondValue);
return durations[Math.floor(durations.length / 2)];
}
Três regras de disciplina:
- Use uma amostra fixa e nunca gere dados aleatoriamente, ou os números não serão comparáveis;
- Rode pelo menos 9 vezes e pegue a mediana; a primeira execução inclui aquecimento do JIT e precisa ser descartada;
- Registre o ambiente de execução (versão do Node, CPU), ou comparações entre máquinas não fazem sentido.
8. Lista de verificação
- [ ] Existem números de linha de base para que antes e depois possam ser comparados;
- [ ] A análise de qualquer coisa maior que 1MB roda em um Worker;
- [ ] As visões em árvore expandem sob demanda e as listas usam rolagem virtual;
- [ ] Nenhuma cópia profunda desnecessária nem envolvimento em reatividade;
- [ ] JSON Lines é usado quando os dados são "um lote de registros";
- [ ] A API envia JSON compacto e a formatação acontece apenas para exibição.