Pruebas de Estrés JSON de 100 MB: Rendimiento de Workers, Límites de Memoria y Coalescencia de Alternancias
Por Aerisium Core · ·
Un archivo JSON de 100 MB no debería colapsar una pestaña del navegador. El enfoque ingenuo garantiza el fallo a esta escala: analizar en el hilo principal, debouncear cada alternancia, mantener el DOM completo en memoria. Pero reemplazar cada uno de esos patrones con una alternativa correcta (Web Worker, referencias de coalescencia, virtualización del DOM) produce una arquitectura que sobrevive a la entrada peor sin congelamiento visible.
Este artículo documenta mediciones reales de la carga y manipulación de un payload JSON de 102 MB en un editor JSON basado en navegador.
Entorno de Prueba
| Parámetro | Valor |
|---|---|
| CPU | Intel Core i5-8250U @ 1.60GHz (4 núcleos, 8 lógicos) |
| RAM | 8 GB |
| SO | Windows 10 22H2 |
| Navegador | Chrome 149.0.7827.201 |
| Payload | 102.67 MB JSON, 1.601.887 claves |
El payload consistía en 180.000 objetos, cada uno con un campo id y una cadena data de 400 caracteres, envueltos en un array. Esta estructura imita un volcado de logs grande o una exportación de base de datos.
Formateo Inicial: Rendimiento del Worker
El archivo se cargó mediante arrastrar y soltar. FileReader.readAsText() produjo una cadena raw, que se envió a un Web Worker para análisis y formateo. El manejador del worker se instrumentó con performance.now() para aislar el tiempo de parseo del tiempo de stringify:
const parseStart = performance.now();
parsedObj = JSON.parse(code);
parseTimeMs = performance.now() - parseStart;
const stringifyStart = performance.now();
result = JSON.stringify(transformed, null, indent);
stringifyTimeMs = performance.now() - stringifyStart;
| Fase | Tiempo |
|---|---|
| Round-trip total del worker | 2.463 ms |
JSON.parse() | 581 ms |
JSON.stringify() con indentación | 409 ms |
Los ~1,5 segundos restantes fueron consumidos por el conteo de claves (recorrido recursivo de todas las 1,6M claves) y una operación de ordenación por clonación profunda (sortObjKeys) cuando la ordenación de claves estaba activa.
El total de 2,5 segundos está muy por debajo del umbral de “Página no responde” del navegador. El hilo de UI permaneció completamente responsivo porque todo el cómputo pesado se ejecutó en el hilo de fondo del worker.
Asignación de Memoria Tras el Parseo Inicial
Una instantánea del heap tomada inmediatamente después de que el worker devolvió el resultado:
| Medición | Valor |
|---|---|
| JS Heap (formateo inicial) | 232 MB |
| Memoria total de la pestaña | 647 MB |
Una cadena de 102 MB se expande a aproximadamente 4 veces su tamaño de archivo en memoria debido a la codificación UTF-16 de JavaScript. La sobrecarga restante incluye el árbol de estado del editor, la cadena de salida formateada y la representación interna de objetos de V8 de la estructura analizada. V8 asigna memoria para Hidden Classes, backing stores de propiedades y backing stores de elementos para cada objeto en el array. Cada uno de los 180.000 objetos carga una sobrecarga estructural fija más allá de los bytes de clave y valor.
Coalescencia de Alternancias: Referencia de Sincronización
El enfoque ingenuo basado en tiempos (un debounce de 50 ms en cola) falla porque solo retrasa —no cancela. Cada alternancia encola una solicitud de worker separada, y cuando el worker está ocupado durante 2+ segundos por ciclo, la cola crece sin límite.
El enfoque correcto es una referencia de sincronización:
// Se establece sincrónicamente en el manejador de clic cuando el worker está ocupado
pendingToggleRef.current = true;
// Se verifica en el callback onmessage del worker — envía solo el estado final
if (pendingToggleRef.current) {
pendingToggleRef.current = false;
worker.postMessage({ ...latestState });
}
No se crea ninguna cola. El worker recibe como máximo una solicitud por ciclo, que lleva el estado final de alternancia del usuario.
Spam Controlado: 2 Minutos de Abuso Sostenido
Mantener Enter presionado en la alternancia Sort Keys durante 2 minutos seguidos produjo 22 ciclos limpios:
| Métrica | Valor |
|---|---|
| Respuestas totales del worker | 22 |
| Duración | ~120 s |
| Tiempo de ciclo medio | ~5,5 s |
| JS Heap máximo | 270 MB |
| JS Heap (tras GC inactivo) | 183 MB |
Cada ciclo colapsó cientos de pulsaciones de teclas en una sola solicitud. La pestaña nunca se congeló y nunca superó los 270 MB de JS heap.
Las 22 respuestas mostraron parseTimeMs=0, confirmando que cada solicitud tras la primera encontró el objeto en caché del worker. El tiempo de ciclo de 2-4 segundos estaba dominado por la clonación profunda y la ordenación, no por el parseo JSON.
Abuso Extremo: Clics de Alternancia Ininterrumpidos
Un escenario más agresivo —alternando entre múltiples botones de alternancia (sort keys, minify, indent) lo más rápido posible durante 2,5 minutos— empuja la arquitectura a un modo de fallo diferente. La referencia de coalescencia evita la acumulación de cola, pero no impone un intervalo de descanso. Mientras el usuario siga haciendo clic, cada ciclo completado desencadena inmediatamente el siguiente, manteniendo el worker al 100 % de ciclo de trabajo sin ventana de inactividad para la recolección de basura.
Esto produjo más de 50 ciclos consecutivos con la memoria aumentando de forma constante:
| Métrica | Valor |
|---|---|
| Respuestas totales del worker | 50+ |
| Duración | ~150 s |
| JS Heap máximo | 3,2 GB |
Incluso con la memoria al máximo, todos los controles de UI seguían siendo visualmente responsivos. Los desplegables se abrían y cerraban. Los interruptores de alternancia se animaban entre estados marcados y no marcados. El usuario podía interactuar con cualquier botón o menú.
Sin embargo, dos cosas se rompieron:
Barra de estado atascada en “Processing”. Después de que el usuario dejó de hacer clic y la memoria se estabilizó en 1.0-1.1 GB, la barra de estado seguía mostrando “Worker: Processing…” sin recuperación. Bajo presión de 3,2 GB de heap, el scheduler de React eliminó la actualización de estado envuelta en startTransition de la última respuesta del worker, dejando isProcessing permanentemente en true. El temporizador de enfriamiento de 300 ms también fue probablemente eliminado.
El panel de salida dejó de actualizarse. El JSON formateado en el editor de salida nunca reflejó el estado final de alternancia. El worker había completado y enviado su respuesta (visible en los logs de consola), pero setOutputCode() estaba envuelto en startTransition junto con setIsProcessing(false), y ambos fueron eliminados.
Este es un caso límite deliberado, no un error. Si un usuario mantiene Enter en una alternancia durante 2,5 minutos seguidos y empuja su navegador a 3,2 GB de heap, la aplicación opera fuera de sus parámetros de diseño. Cualquier contramedida de ingeniería (temporizador watchdog, enfriamiento forzado) penalizaría el 99,9 % de las interacciones normales solo para defenderse de un escenario que una recarga de página resuelve en 2 segundos. No se puede diseñar una aplicación web para sobrevivir al abuso malicioso sin degradar la experiencia para todos los demás.
La diferencia entre 270 MB y 3,2 GB no es un fallo de coalescencia. Ambos escenarios colapsan los clics en solicitudes únicas por ciclo. El escenario de 3,2 GB simplemente ejecuta 50+ ciclos consecutivos sin margen de respiro, sin dejar ventana para GC o el scheduler de React. Esto está fuera de los parámetros de diseño de la aplicación.
GC Inactivo y Recuperación de Fallo de Caché
El worker emplea un temporizador de inactividad de 60 segundos que libera su objeto interno en caché para evitar la retención de memoria a largo plazo. Después de 60 segundos de inactividad, al hacer clic en una alternancia se desencadena un fallo de caché y el hilo principal reenvía la cadena de entrada completa:
| Fase | Tiempo |
|---|---|
| Detección de fallo de caché | ~0 ms |
| Reenvío de código + parseo en frío | 1.486 ms |
| Stringify | 108 ms |
| Recuperación total | 3.795 ms |
Después de completar la recuperación en frío, el heap se estabilizó en 183 MB, menos que los 232 MB iniciales, lo que indica que el recolector de basura había reclamado las asignaciones intermedias del spam de alternancias anterior.
Perfil de Memoria Completo
| Instantánea | Condición | JS Heap |
|---|---|---|
| 1 | Tras formateo inicial | 232 MB |
| 2 | Tras 2 min de spam de alternancias | 270 MB |
| 3 | Tras 60 s de GC inactivo + recuperación | 183 MB |
| 4 | Tras limpiar | 34 MB |
La aplicación se recupera a una memoria casi basal después de limpiar la entrada, confirmando que no hay fugas en el ciclo de vida del worker ni en la gestión del estado del editor.
Conclusiones Clave
-
La delegación a Web Workers elimina los congelamientos de UI: incluso a 102 MB, el hilo principal permanece responsivo durante todo el procesamiento.
-
La coalescencia de alternancias evita la acumulación de cola:
pendingToggleRefcolapsa N clics en 1 solicitud. Un debounce basado en tiempos no puede garantizar esto porque los retrasos no cancelan. -
La memoria máxima depende de la duración del abuso: bajo spam sostenido de 2 minutos con pausas naturales, el heap alcanza un máximo de 270 MB. Bajo clics ininterrumpidos sin intervalos de descanso (50+ ciclos consecutivos), el heap puede alcanzar 3,2 GB. Un temporizador de enfriamiento entre ciclos eliminaría esta brecha.
-
La recuperación de fallo de caché es gradual: el worker señala la invalidación de caché, el hilo principal reenvía la entrada y el procesamiento se completa sin pérdida de datos.
El patrón de ingeniería que permite esto es sencillo en principio pero requiere disciplina en su aplicación: no bloquear el hilo principal con nada, coalescer cada variación de entrada exactamente en un estado pendiente, y dar a cada asignador un camino de vuelta a cero.