wandres.dev
RENDIMIENTO DEL ESTADO · el coste de reaccionar

Medir de verdad: perfilar antes de optimizar

Casi todas las optimizaciones de estado que se aplican en la práctica atacan un cuello de botella imaginado, y una fracción considerable de ellas empeora el rendimiento porque añade guardias más caras que el trabajo evitado. Esta lección establece un método: formular una hipótesis falsable, elegir el instrumento adecuado a la escala del fenómeno, separar el coste de propagar del coste de renderizar, y validar el cambio con una comparación honesta contra la línea base en condiciones representativas del dispositivo real.

⏱ 18 min

La intuición sobre rendimiento es notoriamente mala, y lo es por una razón estructural: el programador percibe el coste de lo que escribió y es ciego al coste de lo que el sistema hace por él. Por eso las corazonadas apuntan casi siempre al bucle visible y casi nunca a la comparación que corre mil veces por segundo bajo la superficie, ni al efecto que fuerza una relectura de geometría, ni al selector que devuelve un objeto nuevo en cada llamada. Perfilar no es un ritual de verificación que se hace al final: es el único método que distingue una optimización de una superstición. Y en un sistema reactivo tiene una exigencia propia, la de separar dos costes que el perfil mezcla y que responden a remedios opuestos, el de averiguar qué cambió y el de pintar el resultado.

🎯 Al terminar esta lección sabrás
  • Formular una hipótesis de rendimiento falsable antes de tocar una línea de código.
  • Elegir el instrumento adecuado a la escala temporal del fenómeno investigado.
  • Separar en el perfil el coste de la propagación del coste del render.
  • Validar una optimización contra una línea base en condiciones representativas.

Primero la hipótesis, después el instrumento

Una sesión de perfilado sin hipótesis produce datos, no conocimiento. La disciplina consiste en escribir antes de medir una afirmación que la medición pueda desmentir: al escribir en el filtro, el derivado que ordena la lista se recalcula una vez por pulsación y tarda más de ocho milisegundos. Esa frase es útil porque nombra el evento, el trabajo sospechoso, la frecuencia esperada y un umbral. Después de medir, o queda confirmada y se sabe exactamente qué arreglar, o queda refutada y se ha eliminado una explicación falsa, que también es progreso.

⏱️

Contadores

Cuántas veces corrió algo. El instrumento más subestimado y el más informativo, porque no depende de la máquina y señala la línea culpable en vez de un punto caliente difuso.

📈

Marcas y medidas

performance.mark y performance.measure delimitan tramos con nombre que aparecen en la línea de tiempo del navegador junto al trabajo del motor.

🔬

Perfil de muestreo

El panel de rendimiento captura la pila a intervalos regulares. Ideal para localizar el punto caliente cuando no se sabe dónde mirar, inútil para fenómenos por debajo del intervalo de muestreo.

🧪

Banco aislado

Un microbanco que ejecuta la función sospechosa fuera del ciclo de vida de la interfaz confirma o descarta una hipótesis concreta sin el ruido del resto de la aplicación.

La hipótesis cumple además una función que se aprecia solo cuando falla: acota el trabajo. Sin ella, una sesión de perfilado se convierte en una exploración indefinida en la que cada gráfico sugiere otro camino y nunca hay un criterio para parar. Con ella, la sesión termina cuando la afirmación queda decidida, y si queda refutada la siguiente hipótesis se formula con más información que la anterior. Es el mismo motivo por el que un experimento con una pregunta concreta produce conocimiento y una recogida general de datos produce sobre todo la sensación de haber trabajado.

El error más común al elegir instrumento es usar el perfil de muestreo para fenómenos que ocurren muchas veces y duran poco. Mil comparaciones de veinte microsegundos suman veinte milisegundos de trabajo real, pero se reparten entre pilas distintas y ninguna aparece como punto caliente; el perfil las diluye hasta hacerlas invisibles. Un contador las encuentra en la primera ejecución. La regla es que los fenómenos frecuentes y breves se cuentan, y los infrecuentes y largos se muestrean.

// contar es mas informativo que cronometrar cuando el fenomeno es frecuente
let llamadas = 0, cambios = 0
const comparador = (a: Item, b: Item) => {
  llamadas++
  const igual = a.id === b.id && a.v === b.v
  if (!igual) cambios++
  return igual
}
// tras una sesion real: si cambios se acerca a llamadas, la guardia no memoiza nada
// si llamadas crece con el tamaño del estado, el problema es estructural

Hay una cuarta categoría de medición que no aparece en las tarjetas porque no es un instrumento sino una fuente: los datos de usuarios reales. Un percentil recogido en producción sobre miles de sesiones dice cosas que ningún banco local puede decir, empezando por cuántos de tus usuarios están en dispositivos que tardan cuatro veces más que el tuyo. La instrumentación local sirve para diagnosticar y para iterar; la de campo sirve para decidir si merece la pena diagnosticar algo, y esa es la decisión más cara de equivocar porque consume semanas de trabajo antes de revelar su inutilidad.

⚠️
El modo de desarrollo miente en las dos direcciones

Perfilar una compilación de desarrollo produce números que no corresponden a nada. Las comprobaciones adicionales, las advertencias, la doble ejecución de funciones en modo estricto y la ausencia de optimizaciones del compilador inflan unos costes y ocultan otros, y no lo hacen de forma uniforme, así que ni siquiera sirven como comparación relativa. Cualquier conclusión sobre rendimiento debe obtenerse de una compilación de producción, con la limitación de velocidad de procesador activada para aproximar un dispositivo modesto, y sobre datos de volumen realista.

Separar propagación de render

En un sistema reactivo hay dos trabajos encadenados que el perfil presenta juntos y que exigen remedios distintos. El trabajo de propagación consiste en averiguar qué cambió: marcar nodos sucios, recorrer el cono de dependientes, ejecutar comparaciones y recalcular derivados. El trabajo de render consiste en convertir el resultado en salida: construir descripciones de la interfaz, reconciliarlas y escribir en el DOM, con el cálculo de estilo y la composición que el navegador añade después.

flowchart LR
E[escritura] --> P1[marcar nodos sucios]
P1 --> P2[recorrer el cono y comparar]
P2 --> P3[recalcular derivados]
P3 --> R1[construir la salida]
R1 --> R2[reconciliar y parchear el DOM]
R2 --> R3[estilo layout y composicion]

La atribución correcta cambia por completo el remedio. Si el coste está en la propagación, las palancas son estrechar el cono, normalizar el estado, abaratar comparaciones o agrupar escrituras. Si está en el render, las palancas son reducir el número de nodos, virtualizar, aligerar el trabajo por elemento o mover cálculo fuera del camino crítico. Aplicar una palanca del grupo equivocado no produce mejora y además consume el presupuesto de atención del equipo, que es el recurso más escaso de cualquier esfuerzo de optimización.

// atribucion explicita: dos marcas que delimitan cada fase
performance.mark("prop-ini")
aplicarEscrituras()          // propagacion: grafo, comparaciones, derivados
performance.mark("prop-fin")
performance.measure("propagacion", "prop-ini", "prop-fin")

requestAnimationFrame(() => {
  performance.measure("render", "prop-fin")   // hasta el siguiente frame
})

Un atajo de atribución que funciona sorprendentemente bien consiste en desconectar temporalmente una de las dos fases. Si se sustituye el cuerpo del componente por una salida trivial y la interacción sigue siendo lenta, el coste está en la propagación. Si se conserva la salida completa pero se escriben los datos directamente sin pasar por el grafo y la lentitud persiste, el coste está en el render. Este experimento tarda diez minutos, no requiere ninguna herramienta y resuelve la ambigüedad que un perfil de muestreo puede tardar una hora en aclarar.

ℹ️
El coste que no aparece en tu código

Hay trabajo caro que ninguna marca de tu programa captura porque ocurre dentro del navegador después de que tu función termine. Escribir en el DOM invalida el cálculo de estilo y el diseño, y leer una propiedad geométrica inmediatamente después fuerza al motor a recalcularlos de forma síncrona, un patrón que multiplica el coste cuando ocurre dentro de un bucle. El panel de rendimiento sí muestra esas fases porque las mide el propio navegador, y por eso una medición basada solo en tus marcas puede declarar un éxito mientras el usuario sigue viendo tirones.

Validar contra una línea base

Una optimización no está terminada cuando el código cambia, sino cuando se demuestra que el número que motivó el cambio mejoró y que ningún otro empeoró. Eso exige una línea base registrada antes de tocar nada, un escenario reproducible que ejecute la misma interacción sobre los mismos datos, y varias repeticiones, porque una sola medición en un navegador está dominada por el ruido de la recolección de basura y la planificación del sistema operativo.

// escenario reproducible y comparable
function escenario(): number {
  sembrar(50_000)                       // volumen realista, siempre el mismo
  const t = performance.now()
  for (let i = 0; i < 100; i++) interaccionTipica(i)
  return performance.now() - t
}
const muestras = Array.from({ length: 9 }, escenario).sort((a, b) => a - b)
const mediana = muestras[4]             // la mediana resiste los valores atipicos

Un escenario reproducible exige tres condiciones que se incumplen con facilidad. Los datos deben ser idénticos entre ejecuciones, y por eso se siembran desde una semilla fija en lugar de venir de la red. La interacción debe ser la misma, y por eso se dispara desde código en vez de a mano. Y el estado inicial debe restaurarse entre repeticiones, porque una caché que se calienta en la primera vuelta convierte a las ocho siguientes en una medición de otra cosa. Sin esas tres condiciones, la comparación entre antes y después mide diferencias de escenario, no de código.

// separar la fase de propagacion de la de salida dentro del propio escenario
function escenarioDetallado(): { prop: number; salida: number } {
  sembrar(50_000)
  const t0 = performance.now()
  for (let i = 0; i < 100; i++) escribirEstado(i)   // solo grafo
  const t1 = performance.now()
  vaciarYPintar()                                    // solo salida
  const t2 = performance.now()
  return { prop: t1 - t0, salida: t2 - t1 }
}

La mediana es preferible a la media porque una sola pausa del recolector arrastra la media y no mueve la mediana. Y el percentil alto importa tanto como el central: una interacción cuya mediana es de cuatro milisegundos pero cuyo percentil noventa y cinco es de sesenta se percibe como inestable, y esa inestabilidad molesta más que una lentitud uniforme porque rompe la expectativa que el usuario ya se había formado.

La comparación también debe cubrir lo que la optimización podría haber empeorado, porque casi ninguna es gratis. Un memo añadido consume memoria y una comparación por render; una normalización añade una indirección en cada lectura; una virtualización añade cálculo por evento de desplazamiento y complica la accesibilidad. Registrar solo la métrica que mejoró produce un historial de victorias que no corresponde a la experiencia del usuario, y basta con tres o cuatro de esas victorias parciales para que una aplicación sea globalmente más lenta después de un trimestre entero dedicado a acelerarla.

💡
Guarda el número, no la sensación

Anota junto al cambio la métrica antes y después, el escenario exacto y las condiciones del dispositivo. Esa nota vale más que el propio código, porque dentro de seis meses alguien se preguntará si el memo raro que hay en ese archivo sigue haciendo falta, y sin el número no habrá forma de responder salvo repetir el trabajo entero. Las optimizaciones sin medición documentada se fosilizan: nadie se atreve a quitarlas porque nadie sabe qué pasaría, y el código acumula guardias que quizá dejaron de servir hace tres refactorizaciones.

Optimizar sin medir no es arriesgado, es incoherente

La objeción habitual a optimizar sin datos es que resulta arriesgado, que uno puede equivocarse de sitio y perder el tiempo. La objeción es correcta pero se queda muy corta, porque el problema es anterior y más grave: sin medición no existe siquiera el objeto sobre el que se pretende actuar. Optimizar significa reducir un coste, y un coste solo es un hecho cuando alguien lo ha observado; antes de eso es una conjetura sobre el comportamiento de un sistema que se ejecuta en un motor que reordena, que descarta cálculos cuyo resultado no se usa, que especializa funciones según los tipos que ha visto pasar por ellas y que las desoptimiza cuando aparece uno nuevo, sobre un navegador que planifica el trabajo visual según reglas que ningún programador de aplicaciones conoce en detalle, en un dispositivo cuya frecuencia varía con la temperatura y la batería. Contra esa complejidad la intuición no tiene ninguna posibilidad, y no porque el programador sea poco hábil, sino porque razona sobre un modelo del ordenador que dejó de corresponder a la realidad hace décadas. De ahí que las optimizaciones no medidas no sean neutras cuando fallan: añaden guardias que se ejecutan siempre para evitar un trabajo que quizá ocurría raramente, añaden estructuras que consumen memoria para acelerar un camino que quizá no era el caliente, y añaden complejidad permanente a cambio de un beneficio hipotético. Y al hacerlo, cada una establece un compromiso que la siguiente persona no podrá revisar, porque no habrá quedado registro de qué se estaba comprando ni a qué precio. Por eso la medición no es la última fase del trabajo, sino la primera, y su producto principal no es la optimización que la sigue, sino el número que la justifica y que permitirá, algún día, retirarla con la misma confianza con la que se introdujo.

Cierra el ciclo, por último, comprobando el efecto en el usuario y no solo en el banco. Una mejora de treinta milisegundos en una interacción que ya tardaba cuatro no cambia nada, y una de cinco milisegundos en una que rondaba el umbral de un frame lo cambia todo. El valor de una optimización no es la magnitud absoluta que ahorra, sino de qué lado del umbral perceptivo deja la interacción cuando termina.

⚔️ Perfila antes de tocar nada
  1. Escribe una hipótesis falsable sobre el punto lento de tu aplicación, con evento, trabajo sospechoso, frecuencia esperada y umbral, antes de abrir ningún panel.
  2. Instrumenta con contadores el comparador y el drenado, ejecuta una sesión real y decide si tu hipótesis queda confirmada o refutada.
  3. Delimita con marcas la fase de propagación y la de render en la misma interacción y calcula qué fracción del coste pertenece a cada una.
  4. Registra una línea base con nueve repeticiones, aplica la optimización que los datos sugieran y compara medianas y percentiles altos.
  5. Busca en tu proyecto una memoización sin justificación documentada, quítala, mide, y decide con el número si vuelve o se queda fuera.