Por qué Solid es rápido: leer el js-framework-benchmark
El js-framework-benchmark es lo más parecido a una regla objetiva que tiene el frontend, y Solid vive pegado a la cima. Qué mide de verdad su batería de operaciones sobre una tabla, las tres familias de métricas —duración de actualización, arranque y memoria—, por qué la media geométrica ponderada frente a vanilla resiste los valores atípicos, la mecánica de grano fino que explica los números, y cómo leerlos con honestidad: qué certifica el benchmark y qué deja fuera del todo.
En un campo lleno de opiniones, el js-framework-benchmark de Stefan Krause es lo más cercano a una regla objetiva que existe: mismo hardware, mismo Chrome, misma batería de operaciones para decenas de frameworks, y una tabla de resultados que se publica sin piedad. Solid lleva años entre los tres primeros, rozando el rendimiento de JavaScript escrito a mano. Esta lección no celebra el número: lo diseca. Qué operaciones mide, qué significan sus tres familias de métricas, por qué la mecánica de grano fino produce esos resultados, y —lo más importante para un profesional— dónde termina lo que el benchmark certifica y empieza lo que no puede decirte.
- Enumerar las operaciones y las tres familias de métricas: actualización, arranque y memoria.
- Entender la media geométrica frente a
vanillajsy por qué resiste los atípicos. - Explicar con la mecánica de grano fino por qué Solid queda pegado a la cima.
- Leer los resultados con honestidad: qué mide el benchmark y qué deja fuera.
Qué mide, operación por operación
El benchmark no evalúa apps: evalúa una única tabla sometida a una coreografía fija de manipulaciones del DOM. Cada implementación —de vanillajs a React— repite las mismas nueve operaciones de duración, cronometradas decenas de veces y con la mediana como veredicto. Las operaciones son deliberadamente elementales: crear 1.000 filas, reemplazar las 1.000, actualizar el texto de cada décima fila sobre una tabla de 10.000, seleccionar una fila al hacer clic, intercambiar dos filas, eliminar una, crear 10.000 de golpe, añadir 1.000 a una tabla ya poblada y vaciarla entera.
Cada una aísla un coste distinto del renderizado. Crear mide la fabricación de nodos; reemplazar y vaciar miden la reconciliación de listas enteras; la actualización parcial mide la precisión quirúrgica —tocar 1.000 celdas de 10.000 sin rozar las demás—; intercambiar mide el movimiento de nodos preservando identidad. Es un microscopio sobre la parte del trabajo que un framework de UI no puede delegar: traducir un cambio de datos en un cambio de DOM.
La metodología cuida los detalles que invalidarían un benchmark descuidado. Cada operación se cronometra decenas de veces sobre un Chrome controlado por automatización, con una fase de calentamiento previa para que el JIT esté caliente, se descartan los valores atípicos y se reporta la mediana en lugar de la media para que un pico aislado del recolector de basura no distorsione el número. El tiempo medido no es el del click sino el que va desde la acción hasta que el navegador termina de pintar, capturado con las trazas de rendimiento del propio Chrome. Es reproducible, versionado y abierto: cualquiera puede clonar el repositorio y correrlo.
La tabla se publica en dos variantes. En la keyed, cada fila conserva su identidad ligada al dato —es el modelo de For—, así que reordenar mueve nodos existentes. En la non-keyed, los nodos se reutilizan por posición —el modelo de Index—, más barato pero con otra semántica. Comparar un framework keyed con uno non-keyed es hacer trampa: miden contratos distintos. El número honesto para el uso cotidiano es el keyed, porque es el que preserva la identidad que las apps reales necesitan.
El mismo componente de fila se escribe con For en la variante keyed y con Index en la non-keyed; esa única elección decide qué contrato estás midiendo y por qué no se pueden comparar entre sí.
// keyed: identidad por referencia; reordenar mueve nodos (For)
<For each={filas()}>{(fila) => <Fila dato={fila} />}</For>
// non-keyed: reutiliza el nodo por posicion; el dato es un signal (Index)
<Index each={filas()}>{(fila) => <Fila dato={fila()} />}</Index>
Las tres familias: actualización, arranque, memoria
Las nueve operaciones anteriores forman solo la primera familia: duración, el coste en milisegundos de mutar el DOM. Pero un framework rápido en actualizar puede ser lento en arrancar o glotón en memoria, así que el benchmark mide otras dos familias que a menudo se ignoran al citar “el número de Solid”.
La segunda es el arranque: el peso total transferido en kilobytes, el tiempo que el navegador dedica a evaluar el script (script bootup), el trabajo total en el hilo principal y una métrica pesimista de interactividad (consistently interactive). Aquí no cuenta lo rápido que actualizas, sino cuánto JavaScript obligas a descargar, parsear y ejecutar antes de que la página responda. La tercera es la memoria: cuánta se retiene tras cargar, tras crear 1.000 filas, tras repetir actualizaciones, y —la prueba más reveladora— tras crear y vaciar 1.000 filas, que detecta fugas comparando el antes y el después.
Los tres ejes están en tensión. Un runtime que precalcula y cachea todo gana en actualización pero paga en memoria; uno que difiere trabajo gana en arranque pero puede tartamudear luego. Citar solo la duración es contar un tercio de la historia.
El eje de arranque merece un énfasis especial porque su coste no se reparte por igual. En un portátil de gama alta, medio megabyte de JavaScript se descarga y parsea en un parpadeo; en un teléfono de gama media con CPU modesta y red irregular, ese mismo medio megabyte puede costar segundos de parseo y ejecución antes de que la página responda a un toque. El peso del bundle no es un número abstracto: es tiempo de CPU multiplicado por lo lento que sea el dispositivo del usuario. Por eso el runtime de 7 kB de Solid no es una cifra de vanidad, sino una ventaja que se agranda justo en los dispositivos donde más duele, y por eso citar solo la duración —que se mide en una máquina potente— oculta parte de lo que un usuario real experimenta.
Por qué Solid queda pegado a vanilla
Los resultados se normalizan contra vanillajs, la implementación a mano que recibe el factor 1.00. Cada framework obtiene, por operación, un factor igual a su tiempo dividido por el del más rápido, y su nota final es la media geométrica de esos factores. La elección de media geométrica y no aritmética es deliberada: impide que una sola operación catastrófica —o una sola brillante— domine el total, porque multiplica factores en vez de sumarlos. Un framework que va 1.1x en ocho pruebas y 5x en una no puede esconderlo, pero tampoco queda sepultado por ese único tropiezo.
// La nota es la media geometrica de los factores, no la aritmetica:
// la raiz enesima del producto castiga los picos sin dejar que dominen.
const factores = [1.05, 1.1, 1.08, 1.2, 1.03]; // uno por operacion
const nota = Math.pow(factores.reduce((a, b) => a * b, 1), 1 / factores.length);
Solid ronda un factor de media cercano a 1.05-1.12 sobre vanilla en la variante keyed: entre los tres primeros de toda la tabla, y por delante de cada framework con VDOM por un margen estructural, no anecdótico. La razón es la misma en los tres ejes. En actualización, la reactividad de grano fino conecta cada signal directamente con el nodo del DOM que depende de él, así que una actualización parcial toca exactamente esas celdas sin comparar árboles ni re-ejecutar componentes. En arranque, el runtime pesa alrededor de 7 kB porque no transporta un reconciliador; el compilador convierte el JSX en plantillas que se clonan con cloneNode y en expresiones reactivas mínimas. En memoria, no hay árbol virtual ni fibras que retener: solo el grafo reactivo, más ligero que cualquier representación paralela del DOM.
flowchart TD B[js framework benchmark] --> U[duracion actualizar el DOM] B --> A[arranque bytes y TTI] B --> M[memoria y fugas] U -->|grano fino toca el nodo exacto| G[pegado a vanilla] A -->|runtime diminuto sin VDOM| G M -->|sin arbol virtual retenido| G style B fill:#89b4fa,color:#11111b style G fill:#a6e3a1,color:#11111b
El fragmento que el benchmark ejecuta miles de veces es, en esencia, este: una lista keyed que reconcilia por referencia y actualiza celdas por su signal.
import { For } from "solid-js";
// keyed: cada fila sigue a su dato; reordenar mueve nodos, no los recrea
<tbody>
<For each={filas()}>
{(fila) => (
<tr classList={{ danger: seleccionada() === fila.id }}>
<td>{fila.id}</td>
<td><a onClick={() => seleccionar(fila.id)}>{fila.etiqueta}</a></td>
</tr>
)}
</For>
</tbody>
Lo que el compilador emite a partir de ese JSX es la clave de los números de arranque y actualización. No genera funciones de render que se re-ejecutan: genera una plantilla que se clona con cloneNode y una lista de expresiones reactivas, una por cada hueco dinámico, que se atan al nodo exacto. Conceptualmente, la fila de arriba se convierte en algo cercano a esto: un molde estático que se clona por instancia, y efectos mínimos que actualizan solo su celda.
// Conceptual: lo que el compilador produce por fila
const molde = template(`<tr><td></td><td><a></a></td></tr>`);
const fila = molde.cloneNode(true); // DOM real, sin VDOM
const celdaId = fila.firstChild;
effect(() => (celdaId.textContent = fila.id)); // toca solo esta celda
Clonar un molde es de las operaciones más baratas del navegador, y atar un efecto por hueco significa que una actualización parcial ejecuta exactamente tantos efectos como celdas cambian. Ahí están, a la vez, el arranque diminuto —no se envía un reconciliador— y la actualización quirúrgica que roza a vanillajs.
Hay un detalle más que explica la operación de seleccionar fila del benchmark: la delegación de eventos. En lugar de registrar un manejador por fila —diez mil manejadores en la tabla grande—, Solid registra uno solo en la raíz y deja que el evento burbujee, resolviendo en tiempo de despacho qué fila lo originó. Eso abarata tanto la creación —no se instalan miles de listeners— como la memoria —no se retienen—, y es la clase de optimización que en vanillajs escribirías a mano y que en Solid viene de serie por cómo el compilador trata los eventos del JSX.
Leer los números con honestidad
Un profesional que cita este benchmark debe saber qué no mide, porque su fama invita a sobreinterpretarlo. Mide una carga sintética dominada por manipulación bruta del DOM y reconciliación de listas. No mide la obtención de datos, ni el coste de la lógica de negocio, ni la ergonomía del desarrollo, ni la arquitectura de una app real con rutas, formularios y estado compartido. En una aplicación donde el cuello de botella es la red o un cálculo pesado, la diferencia entre el puesto uno y el puesto cinco puede ser ruido frente al tiempo que se va en un fetch.
Un matiz que rara vez se menciona: la implementación de cada framework en el benchmark la escribe alguien que conoce a fondo ese framework y la afina hasta el límite. Eso mide el techo de cada uno —lo mejor que puede dar en manos expertas— y no el rendimiento medio del código que escribe un equipo bajo presión de fechas. Solid tiene una virtud extra aquí que no aparece en la tabla: la distancia entre su código idiomático y su código óptimo es pequeña, porque el grano fino es rápido por defecto y cuesta empeorarlo. En frameworks con re-render, esa distancia es enorme, y el número del benchmark refleja un cuidado que el código de producción rara vez recibe.
Ser el más rápido aquí certifica una cosa concreta y valiosa: que el techo de rendimiento de la capa de renderizado es altísimo y que no pagas un impuesto de runtime por cada actualización. Es la garantía de que, cuando tu interfaz sí sea el cuello de botella —una tabla viva, un editor, un dashboard que se refresca sin descanso—, el framework no será quien te frene. Pero no es una promesa de que toda app en Solid sea rápida: un mal patrón de datos o un cálculo síncrono caro te hunden en cualquier framework. El benchmark mide el suelo del renderizado, no el destino de tu aplicación.
La forma profesional de usar esta tabla, entonces, no es como un decisor único sino como un filtro y una cota. Como filtro, descarta la preocupación de que la capa de renderizado sea tu limitación: si eliges un framework del pelotón de cabeza, sabes que el techo está por encima de lo que tu app necesitará casi siempre. Como cota, te dice cuánto margen tienes antes de que el propio framework aparezca en un perfil de rendimiento. La decisión real la tomas luego con el profiler sobre tu app concreta, tu red concreta y tus dispositivos concretos, que es donde viven los cuellos de botella que este benchmark, por diseño, no puede ver. Respetar esa división del trabajo —el benchmark para el techo, el profiler para tu caso— es lo que separa a quien cita números de quien los entiende.
Y hay un corolario que Solid disfruta y pocos comparten: como su techo está tan alto y su código idiomático tan cerca de ese techo, el margen entre “lo que escribes sin pensar” y “lo óptimo” es estrecho. En la práctica, eso significa menos horas de perfilado y menos reescrituras defensivas, porque el suelo del que partes ya es casi el techo. El benchmark no te promete una app rápida, pero sí te promete que empezarás muy cerca de lo mejor posible, y en ingeniería de rendimiento el punto de partida importa tanto como el destino.
Actualización
Nueve operaciones de DOM sobre una tabla. Grano fino toca el nodo exacto sin diff de arboles. Es donde Solid brilla mas.
Arranque
Bytes transferidos, bootup del script y TTI. Runtime de unos 7 kB sin reconciliador: poco que descargar y ejecutar.
Memoria
Retencion tras poblar y tras vaciar. Sin arbol virtual ni fibras: solo el grafo reactivo, y la prueba de fugas.
Keyed vs non-keyed
Dos benchmarks distintos. Keyed preserva identidad como For; non-keyed reutiliza por posicion como Index.
Conviene entender qué certifica exactamente que Solid viva pegado a vanillajs, porque revela la tesis entera de la reactividad de grano fino. La implementación a mano es rápida por una razón trivial: hace exactamente el trabajo necesario y ni una operación más. Cuando cambia una celda, toca esa celda; no consulta un árbol virtual, no compara versiones, no re-ejecuta funciones para descubrir qué cambió. El coste de un framework es, medido con precisión, la distancia entre lo que hace y ese mínimo. Un framework con VDOM paga esa distancia en cada actualización porque su mecanismo de correctitud —reconciliar dos árboles— es trabajo que vanillajs no realiza. Solid la paga una sola vez, en tiempo de compilación, cuando el compilador cablea cada expresión reactiva con su nodo del DOM; a partir de ahí, en tiempo de ejecución, hace lo mismo que la mano: toca el nodo exacto. Por eso su factor no es 2x ni 1.5x sino 1.1x, y por eso la ventaja es estructural y no una optimización que otro framework pueda alcanzar afinando su reconciliador —la única forma de igualar a la mano es dejar de reconciliar, que es precisamente lo que el grano fino hace—. La lección profunda del benchmark no es “Solid es veloz”; es que la velocidad en frontend no se gana corriendo más rápido sino eliminando trabajo, y que el trabajo más caro que un framework puede eliminar es el que dedica a averiguar qué cambió. Solid lo elimina porque el grafo ya lo sabe. Cuando interiorizas eso, dejas de leer la tabla como un ranking de deportistas y empiezas a leerla como un mapa de cuánto trabajo superfluo carga cada arquitectura, y entiendes que el puesto de Solid no es mérito de esfuerzo sino consecuencia inevitable de no tener un árbol que reconciliar.
- Abre la tabla de resultados y localiza a Solid en la variante keyed; anota su factor medio frente a vanilla y frente al framework con VDOM más popular.
- Compara la fila de un mismo framework en keyed y non-keyed y explica por qué difieren y qué contrato mide cada una.
- Busca un framework rápido en actualización pero pesado en arranque y argumenta en qué tipo de app preferirías cada perfil.
- Encuentra la prueba de memoria de crear-y-vaciar y explica cómo detecta una fuga comparando dos instantáneas.
- Nombra tres costes reales de una app —red, lógica, ergonomía— que este benchmark no mide, y razona cuándo el puesto uno deja de importar.