Saltar al contenido
jsonbeautifiers
Español

Qué se rompe de verdad cuando los archivos JSON crecen

Cada umbral de tamaño en las herramientas JSON viene de un límite concreto, y el límite del que te advierten suele ser el equivocado.

Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.

La exportación llega como un único archivo .json de 40 MB. El resaltador de sintaxis del editor se rinde, y pegarlo en un formateador web deja la pestaña en blanco varios segundos. Alguien sugiere streaming, otro dice que la recursión reventará la pila, y ambos consejos apuntan a problemas que todavía no tienes.

La pregunta útil no es «¿es grande este archivo?» sino «¿con qué límite estoy a punto de chocar?». Solo hay unos cuatro, y llegan en un orden fijo.

La escalera de tamaños

Tamaño Qué pasa
1 MB Nada. El parseo nativo son unos pocos milisegundos y el árbol de objetos ocupa decenas de MB. Funcionan todas las herramientas, incluidas las mal escritas.
10 MB El JSON.parse nativo tarda unos 158 ms. Un tokenizador escrito en JavaScript, como el de este sitio, tarda unos 780 ms en formatear la misma entrada. Construir un árbol de nodos navegable cuesta unos 294 MB de heap. Todo sigue funcionando, pero un parseo síncrono ya dura lo bastante como para parecer un cuelgue.
100 MB Extrapola los mismos números: varios segundos de parseo en un tokenizador JS, y un árbol medido en gigabytes. Aquí es donde las pestañas del navegador empiezan a morir por memoria y no por tiempo. En el servidor sigue siendo rutina.
500 MB+ V8 limita una sola cadena a 536.870.888 caracteres, unos 512 MB de ASCII. En Chrome y Node el archivo no se puede leer a una cadena, así que ninguna herramienta construida sobre ese paso puede tocarlo, esté escrita como esté.

Esas tres primeras filas están medidas con las herramientas de este sitio; tu parser dará otros números. La cuarta no es una cifra de rendimiento, es un techo duro del motor.

El muro de los 512 MB

Toda herramienta JSON de navegador sigue el mismo camino: leer el archivo a una cadena y entregar la cadena a un parser. Ese primer paso es donde muere un archivo muy grande. La longitud máxima de cadena del motor es una constante fija, y FileReader.readAsText o Response.text() sobre cualquier cosa que la supere lanzan un error antes de que corra tu código.

La constante es por motor. V8 se detiene en 536.870.888 caracteres (require('buffer').constants.MAX_STRING_LENGTH en Node de 64 bits), SpiderMonkey en 1.073.741.822 y JavaScriptCore en 2.147.483.647. Firefox y Safari, por tanto, sobreviven a archivos que Chrome rechaza, pero los tres tienen techo, así que una herramienta que deba funcionar en todas partes se diseña contra el de V8.

Un parser por trozos sobre un ReadableStream llega algo más lejos, porque nunca materializa el documento como una sola cadena. Eso resuelve el tope de cadena y no el problema siguiente, que es que el resultado parseado también tiene que caber en memoria. Cualquier cosa por encima de un par de cientos de MB no es un problema de navegador: llévalo a un shell, a un runtime de un lenguaje o a una base de datos.

Por qué el árbol parseado es tanto más grande que el archivo

Que 10 MB de texto se conviertan en unos 294 MB de heap sorprende, pero la aritmética no tiene misterio. Piensa en {"id":1,"ok":true}, que son 18 bytes en disco. En memoria es:

  • Un objeto con una cabecera y un puntero a su forma o mapa de propiedades.
  • Una ranura del ancho de un puntero por propiedad antes de los valores: cuatro bytes en una compilación de 64 bits con compresión de punteros y ocho sin ella.
  • Cada valor de cadena llevando su propia cabecera, campo de longitud y datos de caracteres, y cada clave de cadena haciendo lo mismo salvo que el motor la haya internado.
  • Los valores que no son enteros pequeños almacenados como celdas del heap aparte, con sus propias cabeceras, alcanzadas mediante otro puntero.

La sobrecarga es por nodo, no por byte, así que la proporción empeora cuanto más estructurados están los datos: 10 MB de una sola cadena larga salen baratos, 10 MB de ochenta mil objetos pequeños con ocho claves cada uno no. El resultado de un JSON.parse sin más es más ligero que un árbol de visor que carga metadatos por nodo, pero sigue siendo un múltiplo del original. Presupuesta un orden de magnitud y luego mide tu propia forma.

El mito de la recursión, corregido

La advertencia habitual es que el JSON muy anidado revienta la pila al parsearlo. En un navegador, con un motor actual, eso ya no es cierto. V8 sustituyó el parser JSON recursivo por uno iterativo en la v7.6, y lee un millón de niveles de anidamiento sin quejarse. Medido en Node v24.15.0 con V8 13.6.233.17, el parseo iba bien a profundidades que antes eran fatales.

El fallo se mudó al otro lado. JSON.stringify sigue siendo recursivo y lanza un RangeError unos pocos miles de niveles más abajo; en esa misma compilación, cerca de 4.800:

const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // bien, un millón de niveles

JSON.stringify(deep);              // RangeError: Maximum call stack size exceeded

Ese número no es una constante. Se mueve con el tamaño de pila con el que arrancó el runtime y con lo que haya en la pila cuando ocurre la llamada, así que no es una cifra contra la que diseñar.

De modo que un servicio puede aceptar un payload hostil, parsearlo sin quejarse, almacenarlo y luego caerse cuando intenta registrarlo o volver a emitirlo. Los límites de profundidad siguen correspondiendo a la frontera de entrada, aunque el lado de la entrada sea justamente el que sobrevive.

Otros runtimes son menos indulgentes en ambas direcciones. El módulo json de CPython recurre tanto al decodificar como al codificar, así que una entrada muy anidada lanza RecursionError ya al entrar. Cuánta profundidad aguantas antes de eso depende de la compilación: el escáner en C de CPython 3.14 se rindió aquí cerca de los 14.000 niveles, muy por debajo de lo que V8 acepta. Si cruzas fronteras de lenguaje, la profundidad que tolera tu servicio es la de su salto más estricto.

Streaming, con las partes que la gente hace mal

Streaming significa no sostener nunca el documento entero. Todo lenguaje mayoritario tiene un parser pull para esto.

ijson de Python entrega los valores que coinciden con una ruta prefijo. El prefijo records.item significa «cada elemento del array bajo la clave de nivel superior records», e item es el token literal para un elemento de array, no un marcador para un nombre de campo. Ese es el detalle que la gente hace mal la primera vez:

import ijson

total = 0
with open("events.json", "rb") as f:            # modo binario, no texto
    for record in ijson.items(f, "records.item"):
        if record["status"] == "failed":
            total += 1

print(total)

ijson elige el backend más rápido disponible en tiempo de importación, y un backend en C es muchísimo más rápido que el respaldo en Python puro. Comprueba cuál te ha tocado antes de concluir que el streaming es lento.

El encoding/json de Go hace lo mismo con Decoder, y ahí la trampa es distinta. Llamar a Decode una vez sobre un array de nivel superior decodifica todo el array en un solo slice, que es exactamente lo que intentabas evitar. Tienes que consumir primero el corchete de apertura como token y luego 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) } // lee el '['

for dec.More() {
    var r Record
    if err := dec.Decode(&r); err != nil { log.Fatal(err) }
    process(r)
}

En Node, stream-json (con Pick para seleccionar un subárbol y StreamArray para emitir elementos) o el más antiguo JSONStream hacen lo equivalente, y el JsonParser de Jackson te da el mismo bucle de tokens en la JVM. Todos compran un perfil de memoria constante renunciando a cualquier cosa que necesite el documento completo a la vez.

El formato era el problema

Hacer streaming de un array JSON gigante es trabajo que estás haciendo porque el archivo nunca debió ser un único array. NDJSON, un valor JSON completo por línea, elimina toda la categoría del problema: lees una línea, parseas una línea, la sueltas, y la memoria queda acotada por tu registro individual más grande. Se parte con split, se filtra con grep como texto, se añade sin reescribir, y sobrevive a una escritura truncada con la pérdida de un registro en vez del archivo.

Si hoy estás atrapado con un array y mañana quieres líneas, NDJSON a JSON convierte en ambos sentidos, y el visor de JSON abre cualquiera de los dos.

Por qué 780 ms es una pestaña rota

Un parseo síncrono retiene el hilo principal. Nada se pinta y ningún clic se registra. Pasados unos 100 ms una interacción deja de sentirse instantánea, y pasado un segundo la página se lee como congelada y la persona va a por el botón de recargar. Recargar reinicia el parseo.

El arreglo no es un parser más rápido, es sacar el trabajo del hilo que renderiza. El embellecedor de este sitio parsea y formatea en un Web Worker, así que la pestaña sigue pintando y el estado de progreso es real en lugar de una mentira publicada justo antes de una llamada bloqueante. Esa decisión estructural importa más que cualquier microoptimización del tokenizador.

Cuando la respuesta no es una herramienta

Hay cosas que merece la pena hacer antes de recurrir a nada de lo anterior.

Filtra primero con jq, para que lo que cargues sea pequeño:

jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson

Ten en cuenta que jq a secas lee todo el documento en memoria. Para archivos mayores que la RAM, jq --stream es el modo que no lo hace, a costa de una sintaxis basada en eventos bastante más extraña.

Para una porción que solo quieres mirar, la herramienta de filtro hace la misma selección en el navegador, y el minificador quita el espacio en blanco de formato, que en una exportación con sangrado es una fracción real de los bytes.

Y a veces la respuesta honesta es que esto no es un problema de archivo. Si estás filtrando una exportación de 2 GB una y otra vez, escribirla una sola vez en SQLite o DuckDB y consultarla ahí abarata todas las preguntas siguientes. Un archivo que no dejas de reparsear ya te ha dicho que quiere ser una tabla.