O que realmente quebra quando os arquivos JSON ficam grandes
Todo limiar de tamanho na ferramentaria de JSON vem de um limite específico, e o limite sobre o qual te avisam costuma ser o errado.
Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.
A exportação chega como um único arquivo .json de 40 MB. O realce de sintaxe do editor desiste, e colar aquilo num formatador web deixa a aba branca por vários segundos. Alguém sugere streaming, outra pessoa diz que a recursão vai estourar a pilha, e os dois conselhos miram problemas que você ainda não tem.
A pergunta útil não é “este arquivo é grande” e sim “em qual limite estou prestes a bater”. Só existem uns quatro, e eles chegam numa ordem fixa.
A escada de tamanhos
| Tamanho | O que acontece |
|---|---|
| 1 MB | Nada. O parsing nativo leva alguns milissegundos, a árvore de objetos tem dezenas de MB. Toda ferramenta funciona, inclusive as mal escritas. |
| 10 MB | O JSON.parse nativo leva cerca de 158 ms. Um tokenizador escrito em JavaScript, como o deste site, leva cerca de 780 ms para formatar a mesma entrada. Construir uma árvore de nós navegável para ela custa uns 294 MB de heap. Tudo ainda funciona, mas um parsing síncrono já dura o bastante para parecer travamento. |
| 100 MB | Extrapole os mesmos números: vários segundos de parsing num tokenizador JS, e uma árvore medida em gigabytes. É aqui que as abas do navegador começam a morrer por memória, não por tempo. No servidor continua rotina. |
| 500 MB+ | O V8 limita uma única string a 536.870.888 caracteres, cerca de 512 MB de ASCII. No Chrome e no Node o arquivo não pode nem ser lido para uma string, então nenhuma ferramenta construída sobre esse passo consegue tocá-lo, não importa como foi escrita. |
Essas três primeiras linhas foram medidas na ferramentaria deste próprio site; o seu parser vai diferir. A quarta não é um número de desempenho, é um teto rígido do motor.
O muro dos 512 MB
Toda ferramenta JSON de navegador segue o mesmo caminho: ler o arquivo para uma string, entregar a string a um parser. É nesse primeiro passo que um arquivo muito grande morre. O comprimento máximo de string do motor é uma constante fixa, e FileReader.readAsText ou Response.text() em qualquer coisa acima disso lançam erro antes de o seu código rodar.
A constante é por motor. O V8 para em 536.870.888 caracteres (require('buffer').constants.MAX_STRING_LENGTH no Node de 64 bits), o SpiderMonkey em 1.073.741.822 e o JavaScriptCore em 2.147.483.647. Firefox e Safari, portanto, sobrevivem a arquivos que o Chrome recusa, mas os três têm teto, então uma ferramenta que precisa funcionar em toda parte é projetada contra o do V8.
Um parser em blocos sobre um ReadableStream avança um pouco mais, porque nunca materializa o documento como uma string só. Isso resolve o teto de string e não o problema seguinte, que é o resultado do parsing também precisar caber na memória. Qualquer coisa acima de umas poucas centenas de MB não é problema de navegador: leve para um shell, um runtime de linguagem ou um banco de dados.
Por que a árvore parseada é tão maior que o arquivo
10 MB de texto virarem cerca de 294 MB de heap surpreende, mas a aritmética não tem mistério. Considere {"id":1,"ok":true}, que são 18 bytes em disco. Na memória isso é:
- Um objeto com um cabeçalho e um ponteiro para a sua forma ou mapa de propriedades.
- Um slot da largura de um ponteiro por propriedade antes dos valores, quatro bytes numa build de 64 bits com compressão de ponteiros e oito sem ela.
- Cada valor string carregando o próprio cabeçalho, campo de comprimento e dados de caracteres, e cada chave string fazendo o mesmo, a menos que o motor a tenha internado.
- Valores que não são inteiros pequenos guardados como células de heap separadas com os próprios cabeçalhos, alcançadas por mais um ponteiro.
O custo extra é por nó, não por byte, então a proporção piora conforme os dados ficam mais estruturados: 10 MB de uma única string longa saem baratos, 10 MB de oitenta mil objetinhos com oito chaves cada não. O resultado de um JSON.parse simples é mais leve que uma árvore de visualizador carregando metadados por nó, mas ainda é um múltiplo da origem. Reserve uma ordem de grandeza e depois meça a sua própria forma.
O mito da recursão, corrigido
O aviso padrão é que JSON profundamente aninhado estoura a pilha quando você faz o parsing. Num navegador, num motor atual, isso já não é verdade. O V8 trocou o parser JSON recursivo por um iterativo na v7.6, e ele lê um milhão de níveis de aninhamento sem reclamar. Medido no Node v24.15.0 com V8 13.6.233.17, o parsing ia bem em profundidades que costumavam ser fatais.
A falha mudou de lado. O JSON.stringify ainda recorre e lança um RangeError alguns milhares de níveis abaixo, na mesma build por volta de 4.800:
const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // tudo bem, um milhão de níveis
JSON.stringify(deep); // RangeError: Maximum call stack size exceeded
Esse número não é uma constante. Ele varia com o tamanho de pilha com que o runtime começou e com o que mais estiver na pilha na hora da chamada, então não é um valor contra o qual projetar.
Ou seja, um serviço pode aceitar um payload hostil, fazer o parsing sem reclamar, guardar, e então cair quando tenta logar ou reemitir o mesmo valor. Limites de profundidade continuam pertencendo à fronteira de entrada, mesmo sendo o lado da entrada o que sobrevive.
Outros runtimes são menos tolerantes nas duas direções. O módulo json do CPython recorre tanto na decodificação quanto na codificação, então entrada profundamente aninhada levanta RecursionError já na chegada. Quão fundo você vai antes disso depende da build: o scanner em C do CPython 3.14 desistiu aqui perto de 14.000 níveis, bem abaixo do que o V8 aceita. Se você cruza fronteiras de linguagem, a profundidade que o seu serviço tolera é a do seu salto mais restrito.
Streaming, com as partes que as pessoas erram
Streaming significa nunca segurar o documento inteiro. Toda linguagem mainstream tem um parser pull para isso.
O ijson do Python entrega os valores que casam com um caminho prefixo. O prefixo records.item significa “cada elemento do array na chave de topo records”, e item é o token literal para elemento de array, não um curinga para nome de campo. Esse é o detalhe que as pessoas erram no primeiro uso:
import ijson
total = 0
with open("events.json", "rb") as f: # modo binário, não texto
for record in ijson.items(f, "records.item"):
if record["status"] == "failed":
total += 1
print(total)
O ijson escolhe o backend mais rápido disponível no momento do import, e um backend em C é muito mais rápido que o fallback em Python puro. Confira qual você pegou antes de concluir que streaming é lento.
O encoding/json do Go faz a mesma coisa com o Decoder, e a armadilha lá é outra. Chamar Decode uma vez num array de topo decodifica o array inteiro num único slice, que é exatamente o que você estava tentando evitar. Você precisa consumir o colchete de abertura como token primeiro e depois decodificar elemento a elemento:
f, err := os.Open("events.json")
if err != nil { log.Fatal(err) }
defer f.Close()
dec := json.NewDecoder(f)
if _, err := dec.Token(); err != nil { log.Fatal(err) } // lê o '['
for dec.More() {
var r Record
if err := dec.Decode(&r); err != nil { log.Fatal(err) }
process(r)
}
No Node, o stream-json (com Pick para selecionar uma subárvore e StreamArray para emitir elementos) ou o mais antigo JSONStream fazem o equivalente, e o JsonParser do Jackson te dá o mesmo laço de tokens na JVM. Todos compram um perfil de memória constante abrindo mão de qualquer coisa que precise do documento inteiro de uma vez.
O formato é que era o problema
Fazer streaming de um array JSON gigante é trabalho que você faz porque o arquivo nunca deveria ter sido um array só. NDJSON, um valor JSON completo por linha, elimina a categoria inteira do problema: você lê uma linha, faz o parsing de uma linha, descarta, e a memória fica limitada pelo seu maior registro isolado. Divide com split, faz grep como texto, acrescenta sem reescrever, e sobrevive a uma escrita truncada com a perda de um registro em vez do arquivo.
Se você está preso a um array hoje e quer linhas amanhã, o NDJSON para JSON converte nos dois sentidos, e o visualizador de JSON abre qualquer um dos dois.
Por que 780 ms é uma aba quebrada
Um parsing síncrono segura a thread principal. Nada pinta e nenhum clique registra. Passados uns 100 ms uma interação deixa de parecer instantânea, e passado um segundo a página se lê como congelada e a pessoa vai atrás do recarregar. Recarregar recomeça o parsing.
A correção não é um parser mais rápido, é tirar o trabalho da thread que renderiza. O beautifier deste site faz parsing e formatação num Web Worker, então a aba continua pintando e o estado de progresso é real em vez de uma mentira publicada logo antes de uma chamada bloqueante. Essa escolha estrutural importa mais que qualquer micro-otimização no tokenizador.
Quando a resposta não é uma ferramenta
Algumas coisas valem a pena antes de você recorrer a qualquer das anteriores.
Filtre primeiro com jq, para que a coisa que você carrega seja pequena:
jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson
Note que o jq puro lê o documento inteiro na memória. Para arquivos maiores que a RAM, jq --stream é o modo que não faz isso, ao custo de uma sintaxe baseada em eventos bem mais estranha.
Para uma fatia que você só quer olhar, a ferramenta de filtro faz a mesma seleção no navegador, e o minificador tira o espaço em branco de formatação, que numa exportação indentada é uma fração real dos bytes.
E às vezes a resposta honesta é que isso não é problema de arquivo. Se você faz grep repetidamente numa exportação de 2 GB, escrevê-la uma vez em SQLite ou DuckDB e consultar lá torna barata toda pergunta seguinte. Um arquivo que você fica reparseando já te disse que quer ser uma tabela.