FAQ de rendimiento y seguridad: lento, con tirones y datos no confiables
En resumen: Por qué un archivo JSON de decenas de MB no abre, cuánto cuesta un clonado profundo, cómo evitar la contaminación de __proto__ y por dónde entra el XSS: diez conclusiones de primeros auxilios ordenadas por síntoma.

En resumen: diagnostica primero por síntoma —análisis, renderizado o memoria— y luego sigue el orden de corrección: reducir el volumen de datos, dejar de clonar en profundidad, mover el análisis a un Worker con desplazamiento virtual y hacer streaming de todo lo que supere los 50 MB. Para entradas no confiables, limita el tamaño y la profundidad de anidamiento antes de filtrar __proto__, constructor y prototype.
«JSON es lento» es inexacto nueve de cada diez veces; lo que realmente es lento suele ser el renderizado, el clonado profundo o un orden de operaciones equivocado.
1. Por qué es lento
Analizar un archivo JSON grande es lento. ¿Cómo lo optimizo?
Localiza primero el cuello de botella y habla después de optimización. Las etapas difieren en varios órdenes de magnitud:
| Etapa | Tiempo aproximado para 10 MB de datos | Por qué es el cuello de botella |
|---|---|---|
| Transferencia de red | Depende del ancho de banda | El JSON sin comprimir suele comprimirse a cerca de 1/5 |
JSON.parse |
20–80 ms | Una implementación nativa, ya rápida |
| Recorrido recursivo / clonado profundo | 200–2000 ms | Procesamiento nodo a nodo en JS puro |
| Renderizado DOM (vista de árbol) | Segundos, se congela con facilidad | El número de nodos se dispara |
Así que el orden de optimización debería ser:
- Reducir el volumen de datos: haz que la API pagine, devuelva solo los campos necesarios y active gzip/brotli;
- Evitar el clonado profundo: véase la sección siguiente; es el coste oculto más habitual;
- Renderizado diferido: en la vista de árbol renderiza solo los niveles expandidos en lugar de generar decenas de miles de nodos DOM a la vez;
- Streaming: no analices todo de golpe cuando los datos superan los 10 MB.
Si el cuello de botella es realmente JSON.parse, eso significa que los datos ya son de decenas de MB o más, y lo correcto es un analizador de streaming:
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// Procesar los elementos del array uno a uno; el uso de memoria es independiente del tamaño total
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
Un JSON de decenas de MB bloquea el navegador en cuanto se abre. ¿Y ahora qué?
Confirma primero si «se bloquea» o «se congela». Los dos requieren un tratamiento completamente distinto:
- Congelación: la pestaña muestra «Page unresponsive» y puede recuperarse tras decenas de segundos. La causa son tareas largas que saturan el hilo principal;
- Bloqueo: la pestaña se convierte en «Aw, Snap», normalmente por agotamiento de memoria (OOM).
En cualquier caso, no lo proceses en el hilo principal. Mueve el análisis a un Web Worker:
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Un Worker resuelve «la interfaz no se congela», no el techo de memoria. Una pestaña de navegador suele tener entre 1 y 4 GB disponibles; un JSON de 200 MB analizado en objetos puede ocupar 1 GB o más (cada par clave-valor arrastra sobrecarga de objeto), y con el renderizado encima es fácil alcanzar el límite.
La solución real es no cargarlo todo nunca:
| Tamaño de datos | Enfoque |
|---|---|
| < 5 MB | Analizar directamente con JSON.parse, vigilar el renderizado |
| 5–50 MB | Análisis en Worker + desplazamiento virtual |
| > 50 MB | Análisis en streaming, conservar solo los campos necesarios |
| > 500 MB | Cambiar el formato (Parquet / SQLite / JSON Lines fragmentado) |
¿JSON.parse(JSON.stringify(obj)) es un clonado profundo?
Es un clonado profundo, pero «cambia datos silenciosamente» en varios puntos, así que no sirve como herramienta de clonado general.
const source = {
date: new Date(),
fn: () => {},
undef: undefined,
nan: NaN,
map: new Map(),
nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
// date: "2026-10-30T03:00:00.000Z", // Date se convierte en cadena
// nan: null, // NaN se convierte en null
// nested: { a: 1 } // solo este nivel es correcto
// }
// fn y undef desaparecen, map se convierte en {}
La lista de problemas:
| Tipo | Resultado |
|---|---|
Date |
Se convierte en una cadena ISO |
NaN / Infinity |
Se convierte en null |
undefined / funciones / Symbol |
Desaparece dentro de los objetos, se convierte en null dentro de los arrays |
Map / Set |
Se convierte en {} |
BigInt |
Lanza TypeError |
| Referencias circulares | Lanza TypeError |
Los navegadores modernos tienen structuredClone, que admite Date, Map, Set y referencias circulares, pero no funciones:
const copy = structuredClone(source);
Si solo es una fusión superficial de configuración, el operador de propagación es la opción más barata: { ...config, timeout: 3000 }.
¿Por qué expandir la vista de árbol da tantos tirones?
Porque cada nodo es un elemento DOM, y cuando el número de nodos llega a decenas de miles, el coste de maquetación del navegador se dispara.
Un JSON de 10 MB puede contener entre 50k y 100k nodos hoja. Aunque renderices solo el área visible, construir el DOM una vez lleva de cientos de milisegundos a segundos, y cada desplazamiento posterior dispara un reflujo.
El enfoque correcto:
- Desplazamiento virtual: renderiza solo las pocas decenas de filas dentro del viewport y reutiliza los nodos DOM al desplazarte;
- Contraer por defecto: expande solo los dos primeros niveles y renderiza los niveles más profundos bajo demanda;
- Limitar la escala de renderizado: por encima de 10k nodos, muestra «Datos demasiado grandes, usa la búsqueda para localizarlos» en lugar de forzar un renderizado.
Esto también explica por qué el editor de este sitio se degrada con elegancia con archivos grandes: no porque técnicamente no pueda renderizarlos, sino porque renderizarlos sería inutilizable.
2. Por qué se ataca
¿Cómo funciona la inyección de __proto__?
Contaminación de prototipos. Un atacante construye un JSON con una clave __proto__; si tu código lo fusiona en un objeto sin filtrar,
sobrescribe Object.prototype y afecta a todos los objetos del runtime.
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Código peligroso: fusión recursiva sin comprobar los nombres de clave
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true — todos los objetos han quedado contaminados
El dato clave: el propio JSON.parse no contamina el prototipo; {"__proto__": ...} se analiza como una propiedad propia ordinaria.
Lo peligroso es fusionar, asignar, Object.assign, lodash.merge y operaciones similares después del análisis.
Tres defensas:
- Filtrar los nombres de clave: omitir
__proto__,constructoryprototypeal fusionar; - Usar
Object.create(null)como contenedor para que no haya cadena de prototipos que contaminar; - Usar un
Mappara almacenar pares clave-valor no confiables.
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
Ten en cuenta además que for...in recorre las propiedades heredadas; Object.keys() es más seguro.
¿Qué debo vigilar al analizar JSON no confiable?
Hazlo en este orden — bajo coste, alto rendimiento:
- Limitar el tamaño: rechazar de inmediato cualquier cosa por encima del umbral, sin leerla primero en memoria;
- Limitar la profundidad: un JSON anidado cientos de niveles puede desbordar la pila de un analizador recursivo;
- Limitar el número de claves: evita ataques de colisión de hash y amplificación de memoria;
- Filtrar claves peligrosas:
__proto__,constructor,prototype; - Validar la estructura: pasarlo por un Schema para confirmar tipos y campos obligatorios;
- No confiar en
Content-Type: el tipo declarado y el contenido real pueden no coincidir.
Comprobar la profundidad es barato; puedes usar un filtro regex aproximado antes de analizar, o recorrer después de analizar:
function measureDepth(value, depth = 0) {
if (value === null || typeof value !== 'object') {
return depth;
}
let maxDepth = depth;
for (const key of Object.keys(value)) {
maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
}
return maxDepth;
}
Pero este código es a su vez recursivo y puede desbordar la pila con datos maliciosamente profundos. El enfoque seguro es imponer el límite durante el análisis,
usando un analizador que admita una opción de profundidad (algunas bibliotecas ofrecen un parámetro maxDepth).
¿Cómo ocurre el XSS a través de JSON?
JSON en sí no es HTML y no se ejecuta. El XSS ocurre en el punto en que «un valor JSON se concatena en HTML».
Tres vías habituales:
| Vía | Ejemplo | Defensa |
|---|---|---|
| Concatenación en plantilla del servidor | res.send('<div>' + data.name + '</div>') |
Escapar HTML en la salida |
innerHTML en el frontend |
el.innerHTML = data.html |
Usar textContent o sanear |
| Incrustación en script en línea | <script>var d = ${JSON.stringify(data)}</script> |
Escapar <, >, & |
La tercera es la más sutil. JSON.stringify no escapa <, así que datos que contengan </script><script>alert(1)</script>
pueden cerrar la etiqueta script antes de tiempo:
const data = { name: '</script><script>alert(1)</script>' };
// Salida: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
La corrección consiste en convertir los caracteres peligrosos en escapes Unicode:
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
¿Debería limitar la profundidad de anidamiento?
Sí. Es una de las defensas con mejor relación coste-beneficio.
Las consecuencias de no limitarla: las funciones recursivas desbordan la pila, JSON.parse falla en algunas implementaciones cuando la profundidad es excesiva,
y la serialización revienta la pila de la misma manera. Un [[[[...]]]] de 1 KB basta para tumbar un analizador sin protección.
Un umbral razonable depende del negocio:
| Tipo de datos | Límite sugerido |
|---|---|
| Objetos de negocio ordinarios | 10–20 niveles |
| Estructuras de árbol (organigramas, directorios) | 50–100 niveles |
| Gateway de API general | 20 niveles |
¿Qué es el JSON hijacking?
Una vulnerabilidad histórica: los navegadores antiguos permitían que un <script src> de otro origen cargara y ejecutara JSON, y los atacantes robaban datos redefiniendo
el constructor Array o Object.prototype.__defineSetter__.
Las protecciones modernas son bastante completas:
- Los navegadores ya no permiten secuestrar literales de array con
__defineSetter__; - Un
Content-Type: application/jsoncorrecto impide la ejecución de scripts; X-Content-Type-Options: nosniffimpide el sniffing de MIME.
Los sistemas antiguos aún arrastran riesgo residual. Consejo práctico: usa un objeto en el nivel superior, no un array desnudo.
Un envoltorio como {"data": [...]} evita que la respuesta sea una sentencia JavaScript válida ({ se trata como bloque de código),
bloqueando el hijacking a nivel de sintaxis.
¿Qué riesgos tiene registrar JSON en logs?
Tres:
- Información sensible escrita en disco: teléfonos, números de identificación, tokens y contraseñas en claro acaban en el sistema de logs, que suele tener permisos más laxos y retención más larga;
- Inyección de logs: un valor JSON que contenga saltos de línea y prefijos de log falsificados divide un registro en varios y contamina la traza de auditoría;
- Explosión de tamaño: escribir en el log una respuesta completa de 10 MB llena el sistema de logs con rapidez.
Enfoque:
// Redactar y truncar antes de registrar
function safeLog(data) {
const cloned = structuredClone(data);
for (const key of ['password', 'token', 'idCard', 'phone']) {
if (key in cloned) cloned[key] = '***';
}
const text = JSON.stringify(cloned);
return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}
Los sistemas de logging estructurado (como pino) escapan ellos mismos los saltos de línea, pero la redacción y el truncado siguen siendo tu responsabilidad.
3. Referencia rápida
| Síntoma | Qué comprobar primero | Corrección |
|---|---|---|
| Análisis lento | Volumen de datos, si hay clonado profundo | Trocear/streaming, eliminar JSON.parse(JSON.stringify()) |
| La página se congela | Tareas largas en el hilo principal, número de nodos renderizados | Worker + desplazamiento virtual |
| La pestaña se bloquea | Uso de memoria | No cargar todo; cambiar a streaming |
| El clonado profundo pierde datos | Date, NaN, Map |
Usar structuredClone |
| Los objetos tienen de pronto propiedades extra | Contaminación de prototipos | Filtrar __proto__, usar un Map |
| Aparece un script emergente | Cadenas concatenadas en HTML | Escapar <, usar textContent |
| Desbordamiento de pila | Profundidad de anidamiento | Limitar a 10–20 niveles |
| Los logs se llenan | Se registra la respuesta completa | Truncar + redactar |