El balance de memoria de JSON: lo que realmente cuestan las cadenas, los objetos y los arrays grandes
En resumen: Un archivo JSON de 10 MB puede convertirse en 30-50 MB dentro del heap de V8. Descubre lo que realmente cuestan las cadenas, los objetos y los números, cómo tomar una línea base con process.memoryUsage y cuándo ayuda el streaming.

Conclusión: toma primero una línea base con process.memoryUsage, registrando el texto y el grafo de objetos como dos incrementos separados, y después elimina una copia completa antes de probar cualquier otra cosa: nada de JSON.parse(JSON.stringify(x)), nada de envolver el documento en bruto en un sistema reactivo. Reduce luego las referencias de larga vida y considera el streaming al final: su curva de memoria plana merece la pena perder el acceso aleatorio solo cuando el archivo no cabe o cuando necesitas apenas unos pocos campos.
Un documento JSON de 10 MB puede convertirse en 30 MB a 50 MB en el heap después de
JSON.parse, y el pico transitorio es aún mayor. Sin hacer esta aritmética, la "optimización" es pura conjetura.
1. El texto y el grafo de objetos son dos balances distintos
En disco y en el cuerpo de una petición, JSON es una secuencia de bytes. Una vez que entra en memoria, se convierte en dos cosas: una cadena y un grafo de objetos formado por objetos, arrays, números y booleanos. Mantener esos dos balances separados es la única forma de explicar por qué un archivo de 10 MB puede hacer que un proceso consuma 80 MB.
| Forma de los datos | Grafo de objetos frente al tamaño del texto | Coste dominante |
|---|---|---|
| Array plano de objetos (unos 8 campos cada uno) | Aproximadamente 2,5-4x | Una cabecera de objeto y slots de propiedades por elemento |
| Configuración con anidamiento profundo | Aproximadamente 3-5x | Muchos objetos pequeños, cada uno con su propia cabecera |
| Array de cadenas muy largas | Aproximadamente 1,2-2x | Cabeceras de cadena más los datos de caracteres |
| Números grandes con decimales | Aproximadamente 2-3x | Un HeapNumber por valor |
| Documentos con muchas claves repetidas | Proporcional al número de claves | Hashing de claves y preparación de propiedades durante el análisis |
Estos multiplicadores son referencias de orden de magnitud que dependen de la versión de V8, de la compresión de punteros, del número de campos y del contenido de las cadenas. La tabla deja claro un punto: el grafo de objetos se dimensiona por "número de nodos × sobrecoste por nodo", no por el número de bytes del texto.
2. Las cadenas: la partida más subestimada
Una cadena de V8 no es un array de caracteres desnudo. Lleva una cabecera de objeto (un puntero al map, un campo de hash y una longitud), que cuesta aproximadamente una docena de bytes en una compilación de 64 bits típica con compresión de punteros, antes de contar ningún carácter. Los caracteres se almacenan de una de dos formas:
- Cadenas de un byte para caracteres del rango Latin-1: 1 byte por carácter;
- Cadenas de dos bytes cuando el contenido incluye caracteres CJK, emoji o cualquier cosa fuera de Latin-1: 2 bytes por carácter.
Un carácter chino ocupa 3 bytes en un archivo UTF-8, pero 2 bytes por carácter en una cadena de dos bytes, más la cabecera. El tamaño del texto y el tamaño del heap no son la misma medida, y no siempre se mueven en la misma dirección.
Tres detalles importan directamente al trabajar con JSON:
- Los slices mantienen vivo al padre. V8 representa las subcadenas largas como
SlicedString, que guarda una referencia al padre más un offset y una longitud. Guarda 200 caracteres extraídos de un log de 80 MB y los 80 MB enteros seguirán siendo alcanzables hasta que se libere ese slice. - La concatenación crea
ConsString. El uso repetido de+=sobre cadenas grandes construye un árbol de fragmentos que solo se aplana en memoria contigua cuando los caracteres se leen de verdad. Concatenar diez mil veces y luego leer una sola vez produce un gran pico de asignación en ese momento. - Los valores repetidos no se comparten automáticamente. Diez mil registros que contienen
"status": "paid"normalmente obtienen diez mil objetos de cadena independientes; el analizador no los apunta a una constante única. Para ahorrar ese coste tienes que mapear tú mismo las cadenas frecuentes a enums o a números.
Cuando necesitas conservar un extracto corto durante mucho tiempo, conviértelo en una cadena independiente y libera la original:
// Mal: el slice referencia todo el documento, así que logText no se puede recolectar
const summary = logText.slice(0, 200);
// Bien: crear una cadena independiente y luego liberar logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Los objetos: cada clave ocupa un slot
Un objeto plano contiene al menos tres cosas: un puntero a su clase oculta (map), un puntero a su almacén de propiedades y un puntero a su almacén de elementos. Con compresión de punteros cada puntero ocupa 4 bytes, y la cabecera del objeto en el heap suma más, así que incluso un objeto vacío cuesta aproximadamente entre dieciséis y veintitantos bytes. En una compilación de 64 bits sin compresión de punteros, duplica esa cifra.
Las propiedades con nombre acaban en uno de dos lugares:
- Propiedades dentro del objeto (in-object): slots colocados directamente después de la cabecera del objeto, lo que ofrece el acceso más rápido;
- Un
PropertyArray: cuando los slots in-object se agotan, las propiedades pasan a un array separado y el objeto guarda un puntero a él.
Cada slot mide 4 u 8 bytes, y la cadena de la clave se contabiliza por separado. Es por eso que los nombres de campo largos se convierten en un coste real en cientos de miles de registros, y no solo en una cuestión estética.
La buena noticia es que las clases ocultas se comparten: un millón de objetos con las mismas claves en el mismo orden comparten un solo map en lugar de copiar la información estructural. La mala noticia es que estos patrones rompen ese reparto o empujan el objeto hacia una representación más costosa:
- Algunos registros tienen un campo
remarky otros no, lo que produce dos formas y una comprobación extra de forma en cada acceso; - Distintos órdenes de inserción pueden producir formas distintas;
- Un número muy elevado de propiedades (más de aproximadamente mil) o
deletefrecuentes empujan el objeto al modo diccionario, donde cada propiedad tiene además una entrada en la tabla hash; Object.create(null)está en modo diccionario desde su nacimiento. Es una buena tabla de búsqueda y una mala elección para un registro con esquema fijo al que se accede en un bucle caliente.
Un error de concepto común merece corrección: JSON permite claves duplicadas, y {"a":1,"a":2} es un documento válido cuyo resultado es a === 2. El objeto final conserva un solo valor, pero el analizador sigue escaneando y asignando cada aparición. Las claves duplicadas no ahorran memoria; solo cuestan tiempo de análisis.
4. Arrays y números: los tipos de elementos marcan la anchura
V8 distingue varios "tipos de elementos" para los arrays, y cada uno tiene una anchura distinta por slot:
| Contenido del array | Tipo de elementos | Coste aproximado por slot |
|---|---|---|
| Enteros pequeños (dentro del rango Smi) | PACKED_SMI_ELEMENTS |
4 bytes |
| Decimales o enteros muy grandes | PACKED_DOUBLE_ELEMENTS |
8 bytes |
| Cadenas u objetos | PACKED_ELEMENTS |
Puntero de 4 bytes más el valor en sí |
Arrays con huecos creados por delete |
HOLEY_* |
Lo mismo, más comprobaciones de huecos |
| Arrays muy dispersos | DICTIONARY_ELEMENTS |
Entradas de tabla hash, normalmente 20+ bytes |
Merece la pena memorizar dos reglas:
Primera, los tipos de elementos solo se degradan. Un array que empieza con enteros pequeños pasa a ser un array de doubles en el momento en que insertas 0.5, y a un array de elementos genéricos cuando insertas una cadena. Nunca se recupera automáticamente; tienes que construir un array nuevo.
Segunda, dónde aterriza un número JSON depende de su valor. Los enteros dentro del rango Smi (aproximadamente más o menos 1,07 mil millones) se almacenan directamente como un Smi y ocupan un slot de puntero. Los números con decimales o exponentes, o los valores fuera de ese rango, asignan un HeapNumber de unos 16 bytes, con un puntero a él almacenado en el array. Así que [1, 2, 3] y [0.1, 0.2, 0.3] no difieren solo en el valor, sino en "un slot de 4 bytes" frente a "un slot de 8 bytes más un objeto en el heap".
Esta es también la raíz del problema de precisión con IDs enteros grandes: más allá del rango de enteros seguros, los números JSON se tratan como doubles y los dígitos de orden inferior simplemente desaparecen. Los arrays JSON llegan empaquetados y sin huecos, así que no hagas delete de elementos después de analizar; eso empuja el array entero hacia una representación más costosa.
5. Los picos de análisis y serialización: el momento en que un proceso muere de verdad
La memoria en régimen estable es un balance; el pico es otro. Mientras se ejecuta JSON.parse(text), el proceso mantiene tres cosas a la vez:
- La cadena de entrada
text; - El grafo de objetos que se está construyendo y aún no está completo;
- Las estructuras temporales propias del analizador.
El pico de análisis es aproximadamente "texto + grafo de objetos + sobrecoste temporal". Después de analizar, si text sigue referenciado por una variable, un closure, una caché a nivel de módulo o un store reactivo, esa memoria del texto nunca se devuelve.
La serialización también tiene un pico. JSON.stringify(data) mantiene el grafo de objetos completo mientras construye la cadena de salida, con búferes de concatenación entre medias. Cuando la salida se acerca al límite de longitud de cadena de V8 (unos 500 millones de caracteres), lanza un RangeError; este fallo suele aparecer en scripts que exportan una base de datos entera como un único documento JSON.
El patrón común más caro es usar la serialización como clonación profunda:
// El pico es aproximadamente: grafo original + texto temporal + grafo nuevo
const safeCopy = JSON.parse(JSON.stringify(bigData));
Esa única línea almacena los mismos datos tres veces. Para trabajo de solo lectura, pasa una referencia. Cuando realmente se requiere aislamiento, usa structuredClone y acepta que también reconstruye el grafo de objetos entero.
6. Medir con process.memoryUsage
En Node, process.memoryUsage() devuelve cinco números a la vez: rss (memoria física usada por el proceso), heapTotal, heapUsed (heap JS usado), external (memoria retenida por objetos de C++) y arrayBuffers (memoria de ArrayBuffer y Buffer). Al estudiar el manejo de JSON, heapUsed es la lente adecuada para el grafo de objetos, y rss te dice si el proceso entero se ha descontrolado.
Un script de medición necesita tres disciplinas: llamar antes a global.gc(), muestrear por etapas y cambiar una sola variable a la vez. Este script registra por separado los incrementos de "leer el texto" y "analizar en objetos":
// Ejecutar: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';
global.gc();
const baseline = process.memoryUsage().heapUsed;
const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;
const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;
console.log({
textOnHeap: afterRead - baseline,
graphOnHeap: afterParse - afterRead,
parsePeak: afterParse - baseline,
});
Algunas trampas:
--expose-gcdebe habilitarse explícitamente; si no,global.gcno existe;heapUsedcubre solo el heap JS. Los Buffers de lectura de archivos viven bajoexternalyarrayBuffers, así que no uses un único número para explicarlo todo;- No hagas
console.logde un objeto grande entre muestras; mantiene viva una referencia temporal; - La primera ejecución incluye el calentamiento del JIT y la compilación perezosa. Ejecuta al menos tres rondas y toma la mediana;
- Para comparar en un navegador, toma instantáneas del heap en el panel Memory de DevTools y lee
Retained Sizeen lugar de mirar fijamente el diente de sierra del Allocation Timeline.
Sin esta línea base, afirmaciones como "cambiar a streaming lo arreglará" o "una caché lo hará más rápido" son solo sensaciones.
7. El streaming compra memoria pico, no tiempo total
El intercambio del análisis en streaming es explícito: acceso aleatorio a cambio de memoria que ya no escala con el tamaño del archivo.
| Enfoque | Memoria pico | Acceso aleatorio | Ideal para |
|---|---|---|---|
JSON.parse completo |
Texto más el grafo de objetos completo | Sí | Por debajo de unos 100 MB y se necesitan la mayoría de los campos |
Un analizador SAX como stream-json |
Un chunk más el elemento actual | No | Archivos enormes, o cuando solo se necesitan unos pocos campos |
| JSON Lines línea a línea | El objeto de una línea | No | Datos que son naturalmente un lote de registros |
| Índice externo o base de datos | Nada cargado por completo | Sí | Consultas repetidas sobre los mismos datos |
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) {
handleItem(value);
}
Este código nunca construye el árbol completo, así que la curva de memoria se mantiene plana. Los costes también son reales: no puedes volver atrás por un campo que te saltaste, las agregaciones globales se vuelven difíciles, la recuperación de errores es responsabilidad tuya, y un escaneo carácter a carácter en JS puro suele ser un orden de magnitud más lento que JSON.parse nativo.
La decisión no es "¿el archivo superó los 10 MB?". Hazte dos preguntas: ¿Necesito la mayoría de los campos después de analizar? ¿Puedo vivir con acceso secuencial? Si ambas respuestas son sí, el análisis completo es lo correcto y tu esfuerzo pertenece a reducir copias y renderizado. Si alguna respuesta es no, considera el streaming. Para la estrategia por capas completa, consulta Análisis, renderizado y streaming de JSON grande.
8. Menos copias, liberación más temprana
En el trabajo con memoria, eliminar una copia completa a menudo vale más que cambiar de analizador:
- Pasa referencias para trabajo de solo lectura. No clones en profundidad "por seguridad", sobre todo no con
JSON.parse(JSON.stringify(x)); - No envuelvas el documento en bruto en un sistema reactivo.
reactivede Vue proxifica todo el grafo de objetos de forma recursiva, y la capa de proxy tiene su propio coste de memoria; - Calcula y cachea los datos derivados por separado. Si una lista solo necesita id, title y status, no metas registros enteros en el store;
- Normaliza las cadenas de alta frecuencia. Mapea valores como
"paid"y"pending"a enums numéricos para ahorrar tanto objetos de cadena como coste de comparación; - Corta las referencias de larga vida. Anula los objetos grandes cuando termines, limpia las cachés y desregistra callbacks para que los closures no los arrastren a la página siguiente;
- Contabiliza el coste de copia de los Workers.
postMessageusa clonación estructurada por defecto; enviar un grafo de objetos de 50 MB de vuelta al hilo principal construye una segunda copia. Devuelve un resumen siempre que sea posible.
La otra mitad del balance de tamaño vive en Deja de comprimir JSON en el orden equivocado: los bytes en disco y los bytes en el cable son una cuenta distinta de la del grafo de objetos en el heap, y ningún porcentaje único explica ambos. Para saber cómo se comportan los editores y las herramientas CLI con la memoria, consulta Editores, herramientas de línea de comandos y automatización.
9. Lista de comprobación
| Síntoma | Causa más probable | Primera acción |
|---|---|---|
| Un archivo de 10 MB lleva el proceso a 50 MB | El grafo de objetos y el texto siguen vivos | Medir por etapas; comprobar si el texto se retiene |
| OOM en el momento del análisis, normal en régimen estable | El pico de análisis se solapa con los datos antiguos | Liberar los objetos antiguos antes de analizar el archivo nuevo |
| La memoria sube despacio y nunca baja | Un slice, una caché o un closure retiene una referencia | Tomar una instantánea del heap y ordenar por tamaño retenido |
| Los arrays son más grandes de lo esperado | Un decimal degradó el tipo de elementos | Unificar los tipos numéricos; evitar delete |
| Añadir una caché lo hizo más lento | Las claves y los valores consumen memoria, elevando la presión del GC | Acotar la caché y guardar solo resúmenes |
¿Por qué la memoria crece tres o cuatro veces después de JSON.parse?
Porque el texto es solo la entrada; el producto del análisis es un grafo de objetos. Cada objeto tiene una cabecera, cada propiedad tiene un slot, y cada cadena tiene su propia cabecera. Con suficientes nodos, el sobrecoste total supera el número de bytes del texto. El multiplicador depende de la forma de los datos, y los arrays planos de objetos difieren mucho de los documentos con anidamiento profundo.
¿Un archivo JSON de 10 MB debe analizarse completo o con streaming?
Mira primero el propósito. Si necesitas acceso aleatorio, la mayoría de los campos y el archivo cabe en memoria, usa JSON.parse completo. Si solo necesitas unos pocos campos, o el archivo no cabe, haz streaming. 10 MB no es una frontera; el streaming normalmente se vuelve obligatorio en algún punto por encima de 100 MB.
¿Por qué process.memoryUsage().heapUsed no coincide con la monitorización del sistema?
heapUsed cubre solo la parte usada del heap JS, excluyendo external, arrayBuffers y el espacio de código. La monitorización del sistema informa rss, que es otra medida. Sigue heapUsed y rss juntos; una brecha grande apunta a Buffers y objetos nativos.
¿JSON.parse(JSON.stringify(x)) es una clonación profunda segura?
No. Mantiene a la vez el grafo original, el texto temporal y el grafo nuevo, así que el pico es aproximadamente tres veces el régimen estable, y además descarta undefined, las funciones y la semántica de Date. Pasa referencias para trabajo de solo lectura; cuando se requiere aislamiento, usa structuredClone y acepta el mismo coste de reconstrucción.