Escalar un frontend
Qué significa que un frontend "escale", por qué el navegador se rompe siempre por los mismos cuatro sitios, y cómo se diagnostica cada rotura antes de proponer un arreglo. Sin dar nada por sabido.
Escalar no es optimizar
La confusión entre estas dos palabras es la razón por la que casi todo el mundo contesta mal la primera pregunta de una entrevista senior.
01Qué es
Optimizar es hacer lo mismo más rápido. Escalar es sostener el crecimiento sin tener que cambiar el modelo cada vez.
Piensa en una carretera. Optimizar es subir el límite de velocidad: llegas antes, pero caben los mismos coches. Escalar es añadir carriles: no vas más rápido, pero caben más. Y hay una tercera cosa, que es lo que de verdad se pregunta en una entrevista: saber cuál de las dos necesita el problema.
El error clásico: te dicen "la lista va lenta" y contestas "le puse memoización a cada elemento". Eso es optimizar. Si el problema es que hay 5.000 elementos puestos, memoizar no escala: sigues teniendo 5.000. Escalar es dejar de tenerlos.
No es "¿cómo lo hago más rápido?". Es "¿qué se rompe primero cuando esto crece diez veces?". Todo lo demás es consecuencia de esa respuesta.
02Por qué sucede
Un sistema no se degrada de forma uniforme: se degrada por el recurso que se agota primero. Si tienes agua, gasolina y tiempo, el viaje lo corta lo que se acabe antes. En un navegador pasa igual, y los recursos son pocos y siempre los mismos.
Por eso la palabra "lento" no significa nada por sí sola. "Lento" puede ser: demasiadas piezas puestas (nodos), demasiado trabajo de una vez (hilo principal), demasiada memoria, o demasiadas idas y vueltas por la red. Son cuatro enfermedades distintas con cuatro tratamientos distintos, y casi nadie empieza preguntando cuál es.
03Cómo se ataca
El método tiene cinco pasos y el orden importa. Si te saltas el primero, optimizas lo que no era; si te saltas el quinto, vuelves al mismo problema en seis meses.
- Cuello de botella. ¿Qué se rompe primero? Nombra el recurso.
- Medición. ¿Con qué número lo sé? Sin número no hay problema, hay una opinión.
- Palanca. Elige una de las cuatro y di qué cuesta.
- Lo que no harías. Descartar una opción razonable demuestra criterio.
- El siguiente límite. ¿Qué se rompe cuando esto crezca otra vez?
El quinto paso es el que casi nadie da y el que más se nota, porque demuestra que piensas en sistemas y no en tickets.
La diferencia se ve mejor en código. Las dos versiones de abajo hacen "lo mismo" para el usuario, pero solo una deja de costar más cuando la lista crece:
// OPTIMIZAR: el mismo modelo, con menos trabajo por fila const Fila = memo(function Fila({ dato }) { return <li>{dato.nombre}</li> }); // sigue habiendo 8.000 filas puestas // ESCALAR: otro modelo — solo existe la ventana visible const [scroll, setScroll] = useState(0); const desde = Math.floor(scroll / ALTO); const hasta = desde + filasVisibles + OVERSCAN; return ( <div style={{ height: datos.length * ALTO }}> // el scroll miente {datos.slice(desde, hasta).map((dato) => ( // solo ~40 existen <Fila key={dato.id} dato={dato} /> ))} </div> );
Si mañana llegan 80.000 filas en vez de 8.000, la primera versión se vuelve 10 veces más lenta. La segunda no se entera: el trabajo depende del tamaño de la ventana, no del tamaño de los datos. Esa es la prueba de que escalaste.
04Cómo diagnosticarlo
"Diagnosticar" aquí significa responder a una sola pregunta: ¿cuál de los cuatro recursos se está agotando? Se contesta con método, no con intuición.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| DevTools › Performance | Tareas largas y repintados | Barras rojas de más de 50 ms; el frame rate cae a 10–15 fps |
| DevTools › Network | Forma de la cascada y peso | Una escalera de peticiones encadenadas, o 2 MB de JS antes del primer texto |
| Elements | Cuántos nodos hay puestos | Miles de elementos en el árbol; el scroll recalcula todo |
| Memory › heap snapshot | Objetos que no se liberan | El conteo de objetos "detached" crece entre dos tomas |
En una entrevista no vas a abrir DevTools. Razona desde los recursos: en un navegador solo se agotan cuatro cosas — nodos, tiempo del hilo principal, memoria y red; en un servidor, otras cuatro — conexiones, escrituras por segundo, tamaño de cola y costo de consulta.
Y dilo así: "Let me figure out which resource this is before I propose a fix." Eso convierte un "no sé" en método.
“Scaling isn't making it faster. It's changing what breaks.”
“In a browser there are only four things to run out of: DOM nodes, main-thread time, memory, and network.”
“This gets us to about X. Past that, the model breaks and here's what I'd change.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Una tabla de 8.000 filas va lenta. Añades memo a cada fila y sigue igual de lenta. ¿Qué falla?
Por qué: el costo no estaba en recalcular cada fila, sino en tener 8.000 filas puestas. Memoizar reduce el trabajo por fila, pero sigue habiendo 8.000. El arreglo de escala es no montarlas.
¿Cuál de estas cuatro frases describe una decisión de escalabilidad y no de optimización?
Por qué: comprimir hace lo mismo más pequeño (optimizar); cambiar a una ventana con cursor cambia el modelo: ya no existe la lista completa en ningún sitio, así que crecer deja de costar lo mismo.
Tu app tarda 9 segundos en aparecer y el servidor responde en 40 ms. ¿Cuál de los cuatro recursos es el sospechoso?
Por qué: si el servidor contesta rápido, el tiempo se está yendo en descargar y ejecutar JavaScript, o en una cascada de peticiones. Los nodos y la memoria se notan al interactuar, no al cargar.
¿Por qué el paso "¿qué se rompe después?" vale tanto en una entrevista?
Por qué: cualquiera puede proponer una solución. Decir en qué punto tu propia solución deja de servir es la señal más clara de que piensas en sistemas y no en tickets.
El navegador por dentro
Todo lo que llamamos "lento" pasa por el mismo pasillo, y ese pasillo tiene una sola puerta. Entenderla es lo que convierte una queja en un diagnóstico.
01Qué es
Cuando abres una página, el navegador no guarda una fotografía: guarda una lista de piezas. Cada botón, cada texto, cada imagen es una pieza con su posición y su estilo. Esa lista se llama DOM, y es la única cosa que el navegador sabe dibujar.
Y aquí está el detalle que lo explica casi todo: el navegador trabaja con un solo par de manos. Colocar las piezas, pintarlas, ejecutar tu JavaScript y responder a tus clics pasan por el mismo sitio, que se llama hilo principal. No hay dos pasillos: hay uno.
La analogía útil es una cocina con un solo cocinero. Puede cortar, cocinar o atender al cliente, pero no las tres cosas a la vez. Si está cortando cebolla durante dos minutos, el cliente que levantó la mano espera dos minutos — aunque el plato esté casi listo.
02Por qué sucede
Para convertir tu código en píxeles, el navegador hace siempre los mismos cinco pasos:
- DOM: lee el HTML y construye el árbol de piezas.
- CSSOM: lee los estilos y construye el árbol de reglas.
- Árbol de render: cruza los dos anteriores; decide qué se dibuja y con qué estilo.
- Layout: calcula dónde va cada pieza y cuánto mide. Aquí es donde "mover una silla obliga a rodear la mesa entera".
- Paint y composición: rellena los píxeles.
Los pasos 3, 4 y 5 se repiten cada vez que algo cambia. Y aquí aparece la primera regla incómoda: cambiar el tamaño o la posición de una pieza puede obligar a recalcular todas las demás. Por eso una lista de 5.000 filas no es "5.000 veces una fila": es un cálculo que las incluye a todas.
La segunda regla es el presupuesto de fotograma. Para que una animación se vea fluida, el navegador tiene unos 16,7 milisegundos por fotograma (60 por segundo). Todo lo que hagas —tu JavaScript, el layout, el paint— tiene que caber ahí. Si una sola tarea ocupa 120 ms, el navegador se salta siete fotogramas seguidos: eso es el tirón.
03Cómo se ataca
Con la cadena en la cabeza, solo existen cuatro formas de intervenir:
Reducir lo que hay
Quitar piezas de la mesa: no montar lo que no se ve, paginar, no renderizar datos que nadie mira.
Reducir lo que haces
Menos trabajo por fotograma: índices, cálculos fuera del render, debounce cuando de verdad aplica.
Mover el trabajo
Sacar cómputo del hilo principal: un Web Worker. El pasillo queda libre; se paga en copiar datos.
No tocar el layout
Animar con transform y opacity evita recalcular posiciones; animar con top o width las recalcula todas.
Leer una medida del DOM y escribir un estilo, en bucle. Por ejemplo: leer
offsetHeight de cada fila y luego fijar su altura. Eso obliga al navegador a
recalcular el layout en cada vuelta del bucle. Es lo que se llama
layout thrashing, y convierte un bucle de 100 elementos en 100 recálculos
completos.
// MAL: alterna lectura y escritura → 100 recálculos de layout for (const fila of filas) { const alto = fila.offsetHeight; // leer fuerza layout fila.style.height = alto + 1 + 'px'; // escribir lo invalida } // BIEN: primero leo todo, después escribo todo → un solo recálculo const altos = filas.map((fila) => fila.offsetHeight); filas.forEach((fila, i) => { fila.style.height = altos[i] + 1 + 'px'; });
04Cómo diagnosticarlo
La regla de oro: no mires, graba. El panel de Performance graba lo que pasó y te deja ver el orden real de las cosas, cosa que la intuición siempre equivoca.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Performance › grabar una interacción | Long Tasks | Barras rojas de más de 50 ms con un nombre de función dentro |
| Performance › Main | Quién ocupa el hilo | Un bloque amarillo enorme de JavaScript donde debía haber muchos pequeños |
| Performance › Rendering | Layout y paint repetidos | Barras moradas y verdes encadenadas; "Recalculate Style" muchas veces por segundo |
| Performance › CPU 4x slowdown | El mismo caso en una máquina lenta | Un tirón que en tu equipo no se ve aparece con claridad |
Tu laptop es más rápida que el teléfono de quien usa el producto. Si no reproduces el problema con la CPU frenada a propósito, no lo estás viendo: lo estás adivinando. Y en una entrevista eso se nota cuando te preguntan "¿y cómo confirmaste que era eso?".
“The browser has a single main thread, and layout, paint and my JavaScript all compete for it.”
“Anything above fifty milliseconds is a long task, and long tasks are what users feel as lag.”
“I'd record the interaction on a throttled CPU before touching the code.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
¿Por qué un clic puede tardar en responder aunque la página "no esté haciendo nada"?
Por qué: todo pasa por un solo hilo. "No estar haciendo nada" visualmente no significa que el hilo esté libre: puede estar calculando o recalculando layout.
Quieres que una barra se deslice suavemente. ¿Cuál de las dos formas evita recalcular posiciones?
Por qué: transform y opacity no cambian la posición de nada más, así que el navegador puede componer sin recalcular el layout. left y width sí afectan al resto de las piezas.
Un bucle lee offsetHeight y luego escribe style.height, por cada fila. ¿Qué está pasando?
Por qué: leer una medida obliga al navegador a poner el layout al día. Si escribes justo después, lo invalidas. El bucle alterna las dos cosas y multiplica el costo por el número de elementos.
¿Cuál es el umbral que se usa para decir que una tarea es "larga"?
Por qué: 16,7 ms es el presupuesto para no perder fotogramas; 50 ms es el umbral práctico a partir del cual la interacción se percibe como lenta. Por eso las herramientas marcan en rojo lo que pasa de 50.
¿Por qué 5.000 filas ahogan al navegador?
Una lista no pesa por lo que ocupa en pantalla, sino por cuántas piezas tiene montadas. Aquí está la cuenta, el truco de la ventana y cómo comprobarlo.
01Qué es: el DOM como lista de piezas
El DOM (Document Object Model, el modelo de objetos del documento) es la lista de piezas que el navegador tiene montadas para tu página. Cada etiqueta se vuelve un nodo: un objeto con su texto, sus clases y una caja con posición y tamaño. Montar un nodo es crearlo, insertarlo en el árbol y darle geometría. El navegador no guarda una foto de lo que se ve: guarda el árbol completo, esté en pantalla o no.
Una tabla de 5.000 filas con 8 celdas no son 5.000 piezas: son 5.000 filas, 40.000 celdas y lo que viva dentro, del orden de 50.000 nodos. Esa es la escala real: en la maqueta son 5.000 filas; en el navegador, 50.000 objetos que alguien mantiene.
02Por qué sucede: el costo está en tenerlas puestas
El scroll es barato porque no cambia nada: desplaza una capa que ya estaba pintada. Por eso una lista enorme puede scrollearse con fluidez y aun así sentirse rota.
El problema aparece cuando algo cambia. Filtras, ordenas o marcas una fila: el navegador invalida estilo y geometría y recalcula nodo por nodo. Con 50.000 nodos montados eso son cientos de milisegundos, y mientras corre, el hilo principal (el único lugar donde el navegador atiende clics y pintado) no hace otra cosa. La página no está lenta: está ocupada.
El costo no está en mirar: está en tener. El scroll perdona porque no toca nada; en cuanto tocas una fila, pagas por todas las que dejaste montadas, aunque estén a 3.000 píxeles.
03Cómo se ataca: la ventana del tren
Piensa en la ventana de un tren: el marco está fijo y el paisaje se desliza por delante. No llevas el paisaje contigo y, al pasar la estación, lo que viste ya no está en tu ventana.
Virtualizar es eso, con tres piezas. Un lienzo alto falso: un contenedor de
5.000 × 36 = 180.000 px para que la barra de scroll represente la lista entera. Montar solo
la ventana: con la posición del scroll, una división y una multiplicación dicen qué filas
deberían verse (unas 40) y solo esas se crean. Y mover con transform:
translateY(i × 36px) desplaza sin recalcular el layout, a diferencia de cambiar
top o la altura.
El overscan son los platos preparados: sacas dos o tres de más para que el siguiente cliente no espere. Montas unas filas extra arriba y abajo para que un scroll veloz no muestre huecos blancos. Si te pasas, llenas la mesa sin necesidad.
¿Por qué filas y no celdas? La fila tiene una sola altura y una sola posición: la cuenta es una multiplicación. Con celdas hay una posición por celda, el ancho de cada columna depende de las demás y la rejilla se calcula entera. Bajas nodos y multiplicas contabilidad.
El precio es un contrato: altura fija. Con filas de dos líneas hay que medirlas, guardar sus alturas y estimar las que no se vieron; la barra se vuelve aproximada y salta al arrastrarla. Casi siempre conviene recortar el texto a una línea.
// Ventana mínima: lienzo que miente y filas movidas con transform const ALTO = 36; // el contrato: todas las filas miden igual const OVERSCAN = 6; // platos de más, arriba y abajo const visibles = Math.ceil(viewport.clientHeight / ALTO); function pintar(filas, scrollTop) { // 1. el scroll cree que hay 5.000 filas lienzo.style.height = filas.length * ALTO + 'px'; const desde = Math.max(0, Math.floor(scrollTop / ALTO) - OVERSCAN); const extra = OVERSCAN * 2; const hasta = Math.min(filas.length, desde + visibles + extra); capa.replaceChildren(); // 2. se desmonta lo que salió for (let i = desde; i < hasta; i += 1) { const fila = document.createElement('div'); fila.className = 'fila'; // 3. mover no obliga a recalcular el layout fila.style.transform = `translateY(${i * ALTO}px)`; fila.textContent = filas[i].nombre; capa.append(fila); } } // .fila { position: absolute; left: 0; right: 0; height: 36px }
Esconder filas con display: none no las quita del árbol: el estilo se sigue recalculando y siguen ocupando memoria. Solo te ahorras layout y pintado.
04Cómo diagnosticarlo
El diagnóstico cabe en una pregunta: ¿cuántos nodos hay montados cuando la página se siente lenta?
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Elements › árbol del DOM | Cuántos nodos cuelgan de la lista: $0.children.length en la consola. |
5.000 tr y decenas de miles de nodos. Abrir el árbol tarda segundos. |
| Performance › pista Main | Las tareas largas, con un triángulo rojo en la esquina de la barra. | Una tarea de 300-800 ms al filtrar, en lugar de muchas pequeñas. |
| Performance › Recalculate Style y Layout | El ancho de las barras moradas dentro de esa tarea. | Recalculate Style de cientos de ms y un Layout casi igual: se recalcula lo que no se ve. |
| Memory › heap snapshot | Nodos retenidos y memoria, con dos capturas y una acción en medio. | Decenas de MB en objetos del DOM; si sube tras cada filtro, además hay fuga. |
“A long list is slow because of how many nodes are mounted, not because of how far you scroll.”
“I keep a tall spacer so the scrollbar tells the truth, and mount only the window plus a small overscan, positioned with transform.”
“I virtualize rows, not cells: one height and one position per item keeps the window math cheap.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
La lista de 5.000 filas scrollea fluida, pero al filtrar se congela medio segundo. ¿Qué explica mejor la diferencia?
Por qué: el scroll solo desplaza una capa ya pintada; filtrar invalida estilo y posición de cada nodo montado. Si el costo viniera de la red, se vería como espera en Network.
¿Para qué sirve el lienzo de altura total (5.000 × 36 px) si dentro solo hay 40 filas?
Por qué: si midiera solo lo montado, la barra sería diminuta y el scroll acabaría en la fila cuarenta: el resto sería inalcanzable. La altura falsa es lo único completo, y no crea nodos.
Filas de altura variable: unas con dos líneas de texto y otras con una. ¿Cuál es la consecuencia más honesta de virtualizarlas?
Por qué: la técnica entera depende de calcular la posición con una multiplicación. Sin alturas conocidas hay que medir, guardar y estimar, así que la barra pasa a ser aproximada y se recoloca cuando la medición contradice la estimación.
¿Por qué se virtualizan filas completas y no cada celda por separado?
Por qué: una fila da una altura y una posición, y eso se resuelve con una multiplicación. Con celdas hay una posición por celda, los anchos dependen unos de otros y la rejilla se recorre completa.
¿Por qué escribir en un buscador congela la página?
Cada tecla dispara trabajo en el único carril que también atiende clics. La salida no es escribir más rápido, sino hacer menos trabajo por tecla.
01Qué es una tarea larga
El navegador hace el trabajo de tu página en el hilo principal: un solo carril donde corre tu JavaScript, se recalculan los estilos, se calcula el layout y se pinta. Un carril, no cuatro.
Una tarea es un tramo de trabajo que el navegador empieza y termina sin soltar el carril. Una tarea larga es la que pasa de 50 ms, el umbral con el que se dice "esto ya se nota". Por debajo, el usuario no lo distingue de la normalidad. Por encima, el clic que llega mientras corre se queda en cola: no se pierde, espera. Si espera 400 ms, la página parece muerta.
02Por qué sucede: hacer menos veces no es hacer menos
Un buscador de 43.200 productos recorre la lista y compara: normaliza cada nombre (minúsculas, acentos fuera) y comprueba si contiene lo escrito. Ese recorrido cuesta lo mismo con una letra que con veinte, porque siempre mira todos.
Escribes "cami": cuatro teclas, cuatro recorridos completos. Si cada uno tarda 120 ms, la cuarta llega cuando el navegador va por la tercera, y la cola crece. A 400 ms por búsqueda, la página parece congelada mientras terminas la palabra.
Aquí se confunden dos cosas: hacer menos veces y hacer menos trabajo. El debounce es lo primero: espera 250 ms de silencio y ejecuta una sola vez. Pasas de seis ejecuciones a una o dos. Pero esa ejecución sigue tardando 400 ms, así que el congelamiento no desaparece: se muda a después de la última tecla. El debounce no arregla el algoritmo; solo cambia cuándo lo llamas.
Bajar el debounce a 50 ms "para que responda antes" devuelve el problema: vuelves a ejecutar en casi cada tecla.
03Cómo se ataca: menos trabajo por tecla
La primera palanca es un índice precalculado: en vez de recorrer 43.200 productos por tecla, construyes una vez un mapa de palabra a ids. Pagas 150-200 ms al cargar y cada búsqueda es una consulta a una tabla: del orden de un milisegundo.
La segunda es la estructura de datos. Si las búsquedas son por prefijo, un índice de prefijos o un árbol de letras evita mirar los que no empiezan así; si la lista está ordenada, una búsqueda binaria encuentra el rango en pocos saltos. Cambiar la forma de guardar suele rendir más que optimizar el bucle.
La tercera es un Web Worker, que se entiende como otro pasillo. El navegador
abre un hilo aparte y ejecuta ahí un archivo tuyo, en paralelo al principal. Lo que no puede
hacer es tocar el DOM: el documento pertenece al hilo principal y el worker no lo ve. Se
hablan por mensajes: postMessage envía y onmessage recibe, y los
datos se copian en el camino (clonado estructurado), no se comparten.
Esa copia es la factura: mandar 43.200 objetos por tecla puede costar más que buscarlos, así que se manda el índice una vez y el worker devuelve solo ids. Por eso un worker no siempre vale la pena: si la tarea dura 20 ms o el cuello es pintar 1.000 resultados, el trabajo sigue en el carril principal. Si es enorme pero divisible, a veces basta cortarla en trozos de 8 ms.
// Una vez, al cargar: normaliza y arma el índice const indice = new Map(); // palabra -> ids de productos for (const p of productos) { // ~43.200 productos, una vez for (const palabra of normalizar(p.nombre).split(' ')) { if (!indice.has(palabra)) indice.set(palabra, []); indice.get(palabra).push(p.id); } } function normalizar(texto) { // "Camión" y "camion" son iguales return texto.toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); } // En cada tecla: una consulta, no 43.200 comparaciones function buscar(consulta) { const clave = normalizar(consulta.trim()); if (!clave) return []; // decenas de ids, no la lista entera return indice.get(clave) ?? []; } // Un prefijo como "cam" no está en el índice: se recorren // las claves (muchas menos que productos) o se guardan.
04Cómo diagnosticarlo
Antes de elegir arreglo, mide el tamaño de la tarea: no es lo mismo 40 ms que 400.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Performance › pista Main | La barra de la tarea que dispara la tecla y su triángulo rojo. | Un bloque de 200-600 ms por tecla; los clics aparecen como espera antes de su handler. |
| Performance › CPU 4x más lenta | La misma grabación con el procesador frenado. | La tarea pasa de 100 a 400 ms y el problema se reproduce en tu máquina rápida. |
performance.now() |
Los ms entre el inicio y el fin del handler, cinco veces. | Números estables de cientos de ms, sin mirar el panel. |
| Performance › Bottom-Up | Qué función consume el tiempo dentro de la tarea. | El 80 % en la normalización y el recorrido, no en pintar: ahí está el algoritmo. |
Mide en frío y con caché caliente. El primer trazo siempre sale peor; el número que importa es el de la segunda y tercera tecla, cuando el usuario ya va escribiendo.
“The main thread is a single lane: any task over 50 ms blocks input and paint.”
“Debouncing reduces how often the work runs; it does not reduce how long one run takes.”
“I precompute an index so a keystroke is a lookup, and move work to a worker only when the copy is cheaper than the compute.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Escribes "cami" y la página se queda muda ~400 ms. ¿Qué pasa?
Por qué: el silencio es del carril: si fuera red, Network mostraría una espera; si fuera pintado, la pista de render estaría cargada. El handler se queda con el único hilo y todo hace cola.
Pones un debounce de 300 ms. ¿Qué mejora y qué no?
Por qué: el debounce cambia la frecuencia, no la complejidad. El área roja de la ejecución que sí corre es la misma, y ahora llega cuando el usuario cree que terminó de escribir.
Mueves la búsqueda a un Web Worker. ¿Cuál es el costo real de esa decisión?
Por qué: los hilos no comparten memoria: lo que envías se clona. El worker gana solo si el cálculo pesa más que la copia; por eso se manda el índice una vez y se devuelven ids. Y pintar los resultados sigue en el hilo principal.
El índice tarda ~180 ms en construirse al cargar. ¿Cómo se justifica ese costo?
Por qué: son 180 ms una vez contra 400 ms por tecla, y el índice puede construirse fuera del camino crítico. El precio real es memoria: puede pesar decenas de MB.
¿Por qué cuatro peticiones se sienten como ocho veces más lentas?
Cuando cada petición necesita la respuesta de la anterior, los tiempos se suman. Se ataca en tres frentes: pedir a la vez, pedir en bloque y no volver a pedir.
01Qué es una cascada
Una petición HTTP es un viaje: el navegador pregunta y el servidor contesta. Aunque la respuesta sea diminuta, cuesta una ida y vuelta (RTT: lo que tarda un mensaje en ir y volver), del orden de 50-200 ms en red móvil. No depende del tamaño del cuerpo, sino de la distancia y de las vueltas.
Una cascada (waterfall) es una secuencia en la que cada petición espera a que termine la anterior. En Network se ve como una escalera: barras que empiezan donde termina la de arriba. Esa escalera es la firma visual de una dependencia.
02Por qué sucede: los tiempos se suman
Piénsalo como mandar a un mensajero. Le pides un tornillo; vuelve, y descubres que faltaba el taquete; lo mandas otra vez; luego, la broca. Cuatro recados encadenados son cuatro viajes. Con cuatro mensajeros a la vez, es uno. Y con la lista completa en una sola nota, también.
La pantalla pide el perfil y, con su id, pedidos, saldo y avisos. Encadenado son cuatro vueltas de 180 ms: unos 720 ms antes de pintar algo. Si las tres últimas solo necesitan un id que ya tienes, pueden salir juntas: 180 ms más pintar.
Un endpoint que agrupa gana incluso a cuatro peticiones perfectas, porque lo que se paga no es el byte: es la vuelta. Cuatro viajes son cuatro colas, cuatro cabeceras y cuatro claves que invalidar; uno solo es una vuelta. Por eso la misma pantalla se siente ocho veces más lenta sin que ninguna petición lo sea: la percepción no suma milisegundos, suma esperas.
03Cómo se ataca: paralelo, bloque y caché
Paralelo. Si dos peticiones no dependen una de otra, no hay razón para esperar: Promise.all las lanza juntas. El navegador abre varias conexiones por dominio, así que el reloj corre una vez. Sirve para lo independiente: no se puede paralelizar lo que necesita un dato que no llegó.
Bloque. Un endpoint que devuelve la pantalla completa convierte cuatro viajes en uno: cuesta un join en el backend y suele ser el arreglo de fondo.
Caché HTTP. El navegador guarda la respuesta si el servidor lo permite: Cache-Control: max-age=60 significa "vale un minuto, no preguntes". Pasado el minuto revalida con ETag y el servidor contesta "no cambió" (304): otra vuelta, pero sin cuerpo.
Caché de aplicación. Aquí guardas tú los datos. El patrón stale-while-revalidate sirve la copia al instante y pide la nueva en segundo plano: el usuario ve algo ya y la pantalla se corrige sola. Es la nota con el teléfono en el corcho: respondes sin mensajero, pero si el número cambió, la nota miente.
El problema difícil es la invalidación: una caché es una promesa sobre cuándo cambian los datos, y eso casi nadie lo sabe. Un TTL es una apuesta; invalidar por evento es exacto pero necesita un canal que avise. Si guardas y no invalidas, el cambio del usuario desaparece al recargar.
El patrón N+1 es la cascada con disfraz: una petición para la lista y una por elemento. Con 30 filas son 31 vueltas, y crecen con los datos. Se detecta contando peticiones por pantalla, no por su lentitud. Se arregla en el servidor con un join o un lote por ids; en el cliente, pidiendo el detalle al abrir la fila.
// En serie: cada await espera a la anterior. Los tiempos se suman. const get = url => fetch(url).then(r => r.json()); const perfil = await get('/api/perfil'); const pedidos = await get(`/api/pedidos?u=${perfil.id}`); const saldo = await get(`/api/saldo?u=${perfil.id}`); const avisos = await get(`/api/avisos?u=${perfil.id}`); // ~4 × 180 ms ≈ 720 ms antes de pintar nada // En paralelo: solo si las tres son independientes del perfil. const [p, s, a] = await Promise.all([ get('/api/perfil'), get('/api/saldo'), get('/api/avisos'), ]); // ~180 ms, pero siguen siendo tres viajes y tres fallos // Agrupado: un viaje y un contrato. Lo pide el frontend, // lo sirve el backend. const resumen = await get('/api/resumen?u=42'); // ~180 ms: una petición que cachear, una clave que invalidar
Con tres datos, no con una opinión: cuántas peticiones hace la pantalla, cuánto es ida y vuelta y qué forma tendría el endpoint que lo sustituye. "Hace 31 peticiones, 30 son una por fila" abre la conversación; "va lento" no.
04Cómo diagnosticarlo
En Network, la forma importa más que la lista de tiempos.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Network › cascada | Si se solapan o van en escalón. | Cuatro barras en escalera: ninguna empieza antes de que acabe la anterior. |
| Network › Queueing y Stalled | El tiempo gris antes de enviar, separado del de respuesta. | Queueing alto: espera a que se libere una conexión. Stalled: prioridad o socket ocupado. |
| Network › TTFB | El tiempo hasta el primer byte. | TTFB alto y descarga diminuta: el problema es la vuelta, no el tamaño. |
| Network › n.º de peticiones | Peticiones al cargar la pantalla. | 31 donde deberían ser 2 o 3: la forma del N+1. |
“Serial requests add up: each one waits for the previous response, so latency multiplies by the number of steps.”
“I parallelize independent calls with Promise.all, but the real win is one endpoint that returns the whole screen.”
“I cache with stale-while-revalidate and treat invalidation as the hard part: a cache key is a promise about when data changes.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Cuatro peticiones de 180 ms encadenadas tardan ~720 ms. ¿Por qué el total no es 180 ms?
Por qué: la suma es hija de la dependencia, no de la red. El navegador abre varias conexiones, pero no puede inventar el id que la siguiente necesita. Si fueran independientes, saldrían juntas y el reloj correría una vez.
Promise.all sobre cuatro peticiones independientes tarda ~180 ms. ¿Qué NO resuelve?
Por qué: el paralelo esconde la espera entre peticiones, no reduce las vueltas. Cada una conserva su cola, su primer byte y su timeout; agrupar cambia la forma. Y del mismo dominio salen varias conexiones.
El usuario guarda un cambio y ve datos viejos durante un segundo. Con stale-while-revalidate, ¿qué está pasando?
Por qué: stale-while-revalidate cambia frescura por velocidad: muestra lo que tiene y se corrige sola. Ver lo viejo un instante es el trato; el error es no invalidar tras escribir, porque la copia vieja vuelve y el usuario cree que no guardó.
En Network ves una petición de lista y 30 pequeñas, una por fila. ¿Qué es y qué se pide?
Por qué: el N+1 es un problema de forma: 1 + N vueltas crecen con los datos y ninguna técnica de cliente borra peticiones. O el servidor hace el join, o se pide un lote de ids. El debounce cambia cuándo se disparan, no cuántas son.
¿Por qué un pico de webhooks tumba el servicio?
Un webhook es un aviso que llega por HTTP sin que lo pidas. Si el handler lo procesa antes de contestar, el pico no rompe la interfaz: rompe la tubería que la alimenta.
01Qué es un webhook y qué es aceptar
Un webhook es una petición HTTP que otro servicio te manda cuando algo pasa; no la pediste, llega. Del lado tuyo hay un handler, la función que atiende esa ruta, con dos trabajos que conviene separar: aceptar (verificar la firma y guardar el aviso tal cual) y procesar (interpretarlo, calcular, escribir en tus tablas).
Si hace las dos antes de responder, cada aviso ocupa un lugar mientras dura el trabajo. Con 5.000 avisos en un minuto (~83 por segundo) y ~40 ms por aviso hay ~4 en vuelo; un pico de diez veces pide ~33 y el servidor no los tiene. Los que no caben esperan: esa espera es la latencia del panel.
Aceptar significa que el aviso está en un lugar durable y alguien lo va a trabajar; procesar, que el efecto ya ocurrió. Un 200 sin procesar es una mentira que el emisor no distingue de la verdad, y por eso no reintenta.
Es como el mesero que clava la comanda en el riel y vuelve a la mesa siguiente. Si cocinara cada plato antes de tomar la siguiente comanda, la fila se saldría a la calle aunque la cocina estuviera libre. El riel es la cola (lista durable de trabajo pendiente), la cocina es el worker (proceso aparte que saca trabajo y lo hace) y el "quedó anotado" es el 202: aceptado, todavía no hecho.
02Por qué sucede: reintentos, duplicados y límites
Los webhooks se reintentan: el emisor no sabe si tu respuesta se perdió y, si tarda, asume lo peor. El reintento puede llegar después de que ya procesaste: la misma visita cuenta dos. La idempotencia evita eso: procesar dos veces el mismo event_id deja el mismo resultado que una. Se guardan los ids procesados (o una restricción única): el reintento se descarta.
Cuando llega más de lo que puedes procesar, algo cede: ese es el backpressure. Rechazar con 429 o 503, descartar lo menos valioso contando cuánto tiraste, o dejar crecer la cola. La tercera parece amable y es la peor: sin límite se come la memoria del servicio de colas y convierte el pico en una caída. Si entran 5.000 por minuto y drenan 800, la cola crece 4.200 cada minuto.
La otra decisión es cuándo sumas. Sumar 40 millones de filas en cada consulta paga el costo siempre; con un contador por minuto, el panel de 24 horas lee 1.440 filas. Eso es agregar al escribir. Pierdes libertad para rebanar después, así que se combina con retención por capas: crudo 7 días, agregados por minuto 90 días, por día 2 años.
03Cómo se ataca: aceptar, encolar y decir la verdad
El handler mínimo no procesa: firma, valida lo justo para encolar y contesta 202. El worker hace el resto, fuera del request.
// POST /webhooks/pedidos: acepta y encola. app.post('/webhooks/pedidos', async (req, res) => { // Firma: sin esto, cualquiera inventa avisos. if (!verificarFirma(req)) { return res.status(401).json({ error: 'firma' }); } // Validación mínima: lo justo para encolar. const { event_id, tipo, datos } = req.body || {}; if (!event_id || !tipo) { return res.status(400).json({ error: 'faltan datos' }); } // Clave de deduplicación: un reintento no duplica. await cola.enviar('pedidos', { event_id, tipo, datos }, { clave: event_id, }); // 202: aceptado, todavía no hecho. return res.status(202).json({ ok: true, event_id }); });
El contrato es lo que el frontend hereda: la cola, los workers y el agregado son backend y no corren en el navegador. Si la ingesta devuelve id y estado, la UI puede mostrar "en cola" en vez de mentir con un "listo"; si el agregado trae as_of y lag_ms, puede decir "actualizado hace 2 minutos". Un frontend senior lo necesita porque el contrato decide qué puede hacer la interfaz.
04Cómo diagnosticarlo
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Métricas del endpoint | p50 y p99 por separado. | p99 alto con p50 estable: cola delante. Si suben los dos, saturación. |
| Panel de la cola | Profundidad y edad del mensaje más viejo. | Sube con el pico y no baja; la edad pasa del minuto. |
| Logs y trazas | 429, 503 y 5xx propios. | 429 frena al emisor; 503 con cola vacía: sin capacidad. |
| Tabla de deduplicación | Conteo por event_id. |
El mismo id dos o tres veces: el efecto se aplicó varias. |
| Traza del request | Si el span de proceso vive en el handler. | Dura lo que el trabajo: no hay 202 real, hay un 200 lento. |
p50 en ~80 ms con p99 en 8 s no es código lento para todos: hay una cola delante.
“The handler checks the signature, validates the shape and enqueues; it returns 202 in milliseconds.”
“Every retry is deduplicated on event_id, so processing twice has the same effect as processing once.”
“Under backpressure we shed low-value events and return 429 with Retry-After instead of growing an unbounded queue.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Tu endpoint responde 202 y encola. ¿Qué significa ese 202 para el emisor?
Por qué: 202 dice "recibí la responsabilidad", no "terminé": el trabajo ya está durable y el emisor deja de reintentar. Un 200 sin procesar mentiría, y el reintento que necesitabas no llegaría.
El emisor reintenta por timeout, pero ya habías procesado. ¿Qué evita que la lectura se cuente dos veces?
Por qué: el timeout no distingue "no llegó" de "llegó y tardó en contestar", y el reintento ocurrirá igual. La defensa es que el efecto sea reconocible por id.
La cola crece más rápido de lo que drena y la memoria llega al límite. ¿Cuál es la peor salida?
Por qué: una cola sin límite no absorbe el pico: lo convierte en caída total y pierdes lo acumulado. Descartar con conteo o rechazar con 429 mantienen el sistema vivo y el daño medido.
El panel pide "15 minutos" y la tabla cruda tiene 40 millones de filas. ¿Qué cambia el contrato?
Por qué: sumar 40 millones en el cliente es la misma operación imposible, movida de sitio y con la red en medio. El agregado se lee barato; as_of evita mostrarlo como tiempo real.
¿Por qué un clic pequeño repinta 120 componentes?
Renderizar en React es volver a ejecutar una función que describe la interfaz. Un clic puede ejecutar 120 sin que nada visible cambie: la pregunta útil no es cuántas, sino cuánto cuesta y por qué se encendieron.
01Qué es renderizar
Un componente de React es una función que devuelve una descripción de un trozo de interfaz: objetos ligeros, no nodos del DOM. Renderizar es ejecutar esa función otra vez y obtener una descripción nueva; React la compara con la anterior (reconciliación) y escribe en el DOM solo lo que cambió (commit). El navegador pinta al final. Son tres pasos y solo el primero corre tu código.
Un componente se vuelve a renderizar por tres motivos: cambió su propio estado, se renderizó su padre (aunque los datos que recibe, sus props, sean idénticos) o cambió algo a lo que se suscribió. Las props distintas no son un motivo aparte: son la consecuencia de que el padre se renderizó. Por eso subir el estado tres niveles ejecuta también a todos los hermanos de abajo.
Un re-render trivial cuesta microsegundos; 120 no se notan. El problema es cuando la función hace trabajo: filtrar 5.000 filas, crear un objeto, recalcular un gráfico. Ahí 120 ejecuciones pueden ser ~40 ms, más de dos frames a 60 Hz (16,7 ms cada uno), y el clic se siente pegajoso.
02Por qué sucede: identidad, contexto y banderas
React compara props con Object.is. Dos objetos con el mismo contenido son referencias distintas: {} no es {}. Si el padre hace <Fila estilo={{ color: 'rojo' }} /> o calcula items.filter(...) en el cuerpo, cada render crea referencias nuevas y memo(Fila) falla siempre. memo no arregla al padre: solo evita el hijo cuando las props son estables.
El contexto es una tubería: el proveedor publica un valor y cada consumidor se suscribe al contexto entero, no a un campo. Si el valor es { usuario, tema } y cambia el tema, también se re-renderizan los que solo leen usuario. Y si el objeto se crea en el render del proveedor, cambia de identidad aunque sus campos sean iguales.
Una máquina de estados enumera las formas que puede tomar la pantalla. Con cuatro banderas (cargando, error, hecho, reintentando) hay 16 combinaciones y 12 son mentiras: "cargando y con error" no existe, pero tu estado lo permite. Una unión discriminada tiene cuatro formas y el campo estado dice cuál; el switch obliga a tratar cada una y el compilador se queja si olvidas una. No es estilo: es hacer imposible lo ilegal.
// Cuatro banderas son 16 estados; cuatro formas, cuatro. type Trabajo = | { estado: 'queued'; id: string; encoladoEn: number } | { estado: 'running'; id: string; intento: number } | { estado: 'failed'; id: string; error: string } | { estado: 'done'; id: string; filas: number }; function resumen(t: Trabajo): string { switch (t.estado) { case 'queued': return 'Espera: ' + minutos(t.encoladoEn) + ' min'; case 'running': // Aquí t.intento existe; en 'queued' no. return 'Intento ' + t.intento + ' en curso'; case 'failed': return 'Falló: ' + t.error; case 'done': return t.filas + ' filas listas'; default: // Si agregas un estado, esto deja de compilar. const imposible: never = t; return imposible; } }
No todo render es un problema. Si la interacción termina en un commit de 3 ms, no hay nada que arreglar; memoizar solo agrega comparaciones y funciones que se quedaron con valores viejos. Mide el commit y, si cabe en un frame, déjalo.
03Cómo se ataca: bajar, partir y suscribirse
El orden importa: bajar el estado, partir el contexto, suscribirse por porción con selectores (useSyncExternalStore) y, al final, memo. Bajar el estado es moverlo al ancestro común más pequeño; a veces el ancestro es el propio input. Partir el contexto es uno por frecuencia de cambio. Un selector te suscribe a una porción: el componente se re-renderiza solo si el valor elegido cambia según Object.is.
Object.is no mira el contenido: mira si es el mismo objeto. Por eso una prop creada en el render derrota a memo.04Cómo diagnosticarlo
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| React DevTools › Profiler | Commits por interacción, componentes y el motivo ("why did this render"). | Dice "parent rendered" con props idénticas: el estado está demasiado arriba. |
| Flamegraph | Tiempo propio de cada componente en el commit. | Una barra ancha concentra el costo; las finas no explican nada. |
| Ranked chart | Suma de tiempo por componente. | Uno se lleva la mayor parte: ese se ataca. |
| Highlight updates | Alcance visual del re-render al interactuar. | Un clic en un botón enciende medio árbol. |
| Performance › Interactions | Milisegundos del clic al siguiente frame. | Más de 16,7 ms por interacción: se pierde un frame. |
console.count() en el cuerpo |
Ejecuciones del componente por interacción. | 120 por clic. |
El Profiler separa "muchos" de "caros": 120 componentes de 0,1 ms no necesitan memo; tres de 12 ms sí. Mira el tiempo propio, no el conteo.
“A component re-renders when its own state changes, when its parent renders, or when a subscribed store changes; new props are a consequence.”
“memo only pays off with referentially stable props, so I fix where the state lives before adding memoization.”
“We model async state as a discriminated union, so loading and failed cannot coexist.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Envuelves Fila en memo y sigue re-renderizándose en cada tecla. ¿Cuál es la causa más probable?
Por qué: memo compara props con Object.is: dos objetos con el mismo contenido son referencias distintas, así que la comparación falla aunque los datos no cambien. Sin props estables, memo es solo un envoltorio.
Un contexto con { usuario, tema } cambia el tema. ¿Quién se vuelve a renderizar?
Por qué: el consumidor se suscribe al contexto completo, no a un campo: el proveedor entrega un valor nuevo y React no adivina qué parte usaste. Partir el contexto o usar selectores crea suscripciones por porción.
¿Por qué una unión discriminada evita el bug de "cargando" y "error" a la vez?
Por qué: con banderas, las combinaciones imposibles existen y solo las evitas con disciplina. Con la unión, "cargando y con error" no se puede escribir y el compilador te obliga a decidir.
El commit de un clic tarda 4 ms. ¿Qué corresponde hacer?
Por qué: el presupuesto de 60 Hz es 16,7 ms por frame; 4 ms dejan margen. Optimizar sin medir cambia un problema conocido por comparaciones y closures que nadie entiende.
¿Por qué el usuario pierde su trabajo al recargar?
Una caché de datos es una copia con fecha. El problema no es guardarla: es saber cuándo deja de ser verdad, qué sobrevive a una recarga y qué pasa con lo que el usuario escribió sin conexión.
01Qué es una caché de datos en el cliente
Una caché de datos en el cliente es una copia de lo que te dio el servidor, guardada con dos datos extra: la clave (qué consulta representa) y la fecha de captura. No es la caché HTTP del navegador, que va por URL y cabeceras; esta la controla tu aplicación y vive en memoria, localStorage o IndexedDB (la base de datos del navegador).
Un dato es fresh mientras está dentro de la tolerancia que elegiste y stale cuando la pasó. Esa tolerancia es de producto: el precio de una acción está viejo a los 5 segundos; el nombre de un usuario aguanta 10 minutos. Con stale-while-revalidate pintas la copia igual y pides la nueva en segundo plano; cuando llega, la reemplazas sin spinner.
02Por qué sucede: invalidar, recargar y perder
El problema real no es llenar la caché, es vaciarla. Después de crear un pedido, la lista cacheada sigue diciendo 19. Tres estrategias: por tiempo (refetch cada N segundos; simple, pero muestra datos viejos y gasta peticiones), por evento (tras una mutación marcas las claves afectadas; preciso, exige saber qué toca cada escritura) y por versión (el servidor devuelve un ETag y el cliente descarta si cambió; robusto, necesita soporte del servidor).
Lo que debe sobrevivir a una recarga no es estado de React: es estado de la URL. Filtros, página, pestaña y búsqueda en searchParams: recargar reconstruye la misma vista, el botón atrás funciona y el enlace se comparte. Lo que no va ahí: tokens, textos largos y borradores. Un borrador se guarda aparte, con una versión, y se recupera al volver.
Una actualización optimista aplica el cambio en la copia local antes de que el servidor conteste, guarda el valor anterior y lo restaura si la petición falla. Se siente instantánea porque no espera la red. El riesgo: muestras una promesa como si fuera un hecho; si el servidor valida distinto, el valor salta hacia atrás. Por eso hay que avisar del fallo y reconciliar con la respuesta del servidor.
Sin conexión, lo que permite que la app abra es el service worker: un script que el navegador instala aparte e intercepta la red aun con la pestaña cerrada. En install precarga el shell (HTML, JS y CSS con hash), en activate borra cachés viejas y en fetch decide: cache-first para archivos con hash, network-first para HTML y datos, y nunca cachear POST ni respuestas con datos personales.
Lo que el usuario escribe sin conexión va a una cola de escritura pendiente (IndexedDB) y se reenvía al volver. Ahí aparecen dos problemas: el orden (un renombre y luego un borrado deben reenviarse en ese orden) y los conflictos (el registro cambió en otro dispositivo). Se resuelve con última escritura gana (simple, pierde datos), merge por campo (necesita la versión base) o un 409 con la versión actual. Casi siempre decide el servidor: ve a todos los escritores y el orden canónico.
03Cómo se ataca: guardar, aplicar y revertir
// Optimista con reversión: guarda, aplica, revierte. async function guardarTitulo(id: string, titulo: string) { const clave = ['job', id]; // 1. copia de seguridad const anterior = cache.get(clave); // 2. la UI ya lo muestra cache.set(clave, { ...anterior, titulo }); marcarPendiente(id, true); try { const ruta = '/jobs/' + id; const guardado = await api.patch(ruta, { titulo }); // 3. manda el servidor cache.set(clave, guardado); } catch (error) { // 4. revierte cache.set(clave, anterior); avisar('No se guardó el título. Intenta de nuevo.'); throw error; } finally { marcarPendiente(id, false); } }
React trae el mismo patrón en useOptimistic: la copia optimista vive mientras la acción está pendiente y se descarta al terminar; si la acción lanza, vuelve al valor real.
04Cómo diagnosticarlo
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Application › Service Workers | Estado y versión del SW. | "waiting" sirviendo HTML viejo; "redundant": falló la instalación. |
| Application › Cache Storage | Entradas por caché y versión. | HTML viejo, dos versiones a la vez, datos privados cacheados. |
| Network › Offline | Qué falla y qué sale de caché. | Todo rojo salvo el shell: abre pero no lee. |
| Consola | Errores 409/412 y "Failed to fetch". | Conflicto sin manejar; fetch sin catch. |
| Network, contar peticiones | Peticiones duplicadas por vista. | Dos GET iguales: falta deduplicación. Dos PATCH: doble envío. |
| Application › IndexedDB | Cola pendiente: tamaño, orden e ids. | Crece y no baja: el reenvío falla en silencio. |
Si al recargar aparece el dato viejo y luego salta, falla la invalidación; si no aparece nada, el service worker no sirve el shell.
“We cache data by query key with a freshness window and serve stale data while revalidating.”
“Optimistic updates keep the previous value so we can roll back and tell the user if the write fails.”
“Offline writes go into an ordered queue with an idempotency key, and the server resolves conflicts.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
La UI muestra el título nuevo y el PATCH falla a los 400 ms. ¿Qué hace la reversión?
Por qué: el valor optimista es una promesa, no un hecho: sin la copia anterior no hay nada que restaurar y el usuario cree que guardó. Recargar borra el contexto y no explica qué pasó.
Dos pestañas editan el mismo registro. ¿Por qué el conflicto se resuelve en el servidor?
Por qué: cada pestaña solo conoce su copia y puede creer que su escritura es la última. El servidor ve ambas y decide con una versión; el cliente propone y avisa.
Tras crear un pedido, el listado cacheado sigue en 19. ¿Qué lo corrige con menos riesgo?
Por qué: el problema no es el tiempo sino un dato que ya sabes que cambió; invalidar por evento es preciso y no castiga al resto. staleTime cero convierte cada visita en una espera.
Un usuario renombra sin conexión y al volver la escritura se reenvía dos veces. ¿Qué falta?
Por qué: el replay puede repetirse por un timeout o un cierre a medias; la única forma de aplicar una vez es una clave estable. Cachear la respuesta no evita el segundo envío.
¿Dónde se construye la página: en el servidor o en tu navegador?
La misma pantalla se puede armar en el servidor, en tu navegador o antes de que nadie la pida. Cada opción te cobra en una moneda distinta: segundos en blanco, JavaScript de más o datos viejos.
01Qué es cada forma de entregar una página
Piensa en un departamento. Puedes comprarlo en obra gris y amueblarlo tú, recibirlo amueblado con la luz sin conectar, o rentarlo de catálogo. La sala se ve igual en los tres casos; cambia cuándo puedes sentarte y cuánto trabajo te toca.
El renderizado en el cliente (CSR, client-side rendering) es la obra gris: el servidor manda un HTML casi vacío y un archivo de JavaScript, el lenguaje con el que el navegador modifica la página después de cargarla. El navegador lo descarga, lo ejecuta y solo entonces construye el árbol de elementos que pinta. Hasta ese momento la pestaña está en blanco, aunque la red ya haya terminado.
El renderizado en el servidor (SSR) es el departamento amueblado: el servidor arma el HTML con los datos dentro y lo manda completo, así que el navegador pinta texto y cajas antes de entender una línea de JavaScript. Falta el enchufe: los botones se ven pero no responden hasta que el JavaScript llega y se ejecuta.
El sitio generado en el build (SSG, static site generation) es el de catálogo: ese HTML se produjo antes de publicar, en el build, el paso previo donde se preparan los archivos sin que nadie lo pida en vivo. Es lo más barato de servir y lo más viejo posible.
La revalidación incremental (ISR) parte de ese archivo y lo regenera cada cierto tiempo, o cuando alguien lo pide después de vencer. Mientras se regenera sigue sirviendo la copia anterior: eso es stale-while-revalidate, servir lo viejo mientras se cocina lo nuevo. Ganas frescura sin una consulta por visita, pero dos personas pueden ver precios distintos durante segundos.
El streaming no cambia dónde se arma la página: cambia cuándo te la mandan. El servidor empieza a escribir la respuesta con la cabecera y el armazón, y sigue escribiendo después; el navegador pinta cada pedazo cuando llega. Si una sección tarda en tener datos, se deja un hueco y se rellena al final.
La hidratación es el momento en que el JavaScript recorre el HTML ya pintado, reconoce cada pieza y le enchufa los eventos: el clic, el teclado, el foco. El HTML estático no sabe nada de ti; la hidratación le enseña a reaccionar. Y aquí aparece el problema: el servidor pintó un árbol y el cliente calcula otro. Cuando no coinciden, el marco de trabajo no puede saber cuál es el bueno y tira el del servidor para rehacerlo. Eso es el parpadeo: el texto cambia, el botón se mueve, el foco se pierde.
Los componentes de servidor (RSC, React Server Components) llevan la idea más lejos: se ejecutan solo en el servidor y no mandan JavaScript. Lo que viaja es una descripción de lo ya armado, no el código que lo armó. La frontera se marca con la directiva 'use client': de ese lado sí viaja todo. Uno de servidor puede leer la base y formatear un precio; uno de cliente puede tener estado y escuchar clics. La pregunta no es cuál es mejor, sino dónde empieza la interacción.
02Por qué sucede
Con SSR el trabajo se paga dos veces: el servidor pinta un árbol y el cliente vuelve a calcular el mismo para poder actualizarlo. Si no coinciden, gana el segundo. Las discrepancias vienen de lo que depende del momento o del aparato: Date.now(), Math.random(), localStorage, el ancho de la ventana, la zona horaria. El servidor no las conoce igual, y el HTML describe algo que ya no es cierto cuando el JavaScript lo revisa.
De ahí la confusión más común: diferir no es reducir. Un import() dinámico (cargar un módulo solo cuando se pide) mueve el costo de lugar, no lo borra: la librería de gráficas de 90 KB sigue pesando 90 KB cuando abres el panel, y ahora hay una espera nueva. Reducir es sacarla del grafo de dependencias: un SVG estático, las funciones del navegador o 40 líneas propias.
La pregunta que decide casi todo: cuánto JavaScript manda tu primera pantalla y qué necesita ser interactivo de inmediato. Un párrafo no necesita JavaScript; un botón de compra sí; un acordeón que nadie abrió, no.
03Cómo se ataca
Decide por superficie, no por moda. Un catálogo que cambia cada hora es candidato a SSG con revalidación; un panel con datos del usuario pide SSR o CSR detrás del inicio de sesión; una página de marketing no necesita hidratación. Cuando se mezclan, el patrón de islas deja el HTML estático y vuelve interactivos solo los huecos que lo requieren.
RSC permite partir en el lugar exacto: el componente de servidor lee los datos y el de cliente maneja el clic; solo el segundo se descarga. Después se pone un presupuesto de bundle (el paquete de archivos que se descarga): un número en kilobytes comprimidos que el build hace cumplir y que falla la integración si se pasa. Para la ruta de inicio, 170 KB comprimidos es un punto de partida; importa menos el número que el hecho de que exista y se mida por ruta.
// app/producto/[id]/page.jsx — corre solo en el servidor // Ni la consulta ni el formateo de precios viajan al navegador. import { getProducto } from '@/lib/productos'; import BotonComprar from './BotonComprar'; // el único que sí viaja export default async function Pagina({ params }) { const producto = await getProducto(params.id); // datos aquí return ( <article> <h1>{producto.nombre}</h1> <p>{formatoPrecio(producto.precio)}</p> {/* La frontera va donde empieza el clic: el título es texto; el botón necesita estado, así que solo el botón es de cliente. */} <BotonComprar id={producto.id} agotado={producto.stock === 0} /> </article> ); } // app/producto/[id]/BotonComprar.jsx 'use client'; // de aquí para abajo, este código se descarga e hidrata import { useState } from 'react'; export default function BotonComprar({ id, agotado }) { const [enviando, setEnviando] = useState(false); if (agotado) return <button disabled>Agotado</button>; return ( <button disabled={enviando} onClick={() => setEnviando(true)}> {enviando ? 'Agregando...' : 'Agregar'} </button> ); }
El comentario del código es la decisión completa: el corte se hace donde cambia la naturaleza del trabajo, no donde queda simétrico. Lo del servidor deja de ser peso descargable; lo que cruza al cliente se justifica con una interacción concreta.
04Cómo diagnosticarlo
El síntoma siempre es el mismo: algo tarda en aparecer o en responder. La herramienta dice cuál de las dos cosas es.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| DevTools › Lighthouse › LCP | El Largest Contentful Paint: cuándo aparece el elemento más grande de la primera pantalla y qué lo retrasa. | Si el LCP cae después de la hidratación, el HTML no traía el contenido: CSR puro o SSR esperando datos. |
| DevTools › Network | El orden de los recursos y la columna Initiator: qué import arrastró qué. | Un HTML de 2 KB seguido de 400 KB de JavaScript es la firma de CSR; varios JS encadenados son una cascada. |
| DevTools › Coverage | Qué porcentaje del código descargado se ejecutó de verdad en esa ruta. | Entre 60% y 80% sin usar: mandas la aplicación entera para pintar una vista. |
| Lighthouse › Reduce unused JavaScript | Cuántos KB se pueden quitar y de qué archivos, con el ahorro estimado. | Un archivo grande con poco uso es candidato a diferir; diez pequeños con poco uso, a borrar. |
Abre la página con 4G simulado y JavaScript deshabilitado: lo que se ve y no se puede usar depende de la hidratación.
“Server-rendered HTML paints before any JavaScript runs.”
“Hydration is a second render, so a mismatch throws away the server tree.”
“The budget is per route: what does this screen need before it is useful?”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
El HTML llega con todo el contenido y se pinta rápido, pero el botón de agregar no responde durante dos segundos. ¿Cuál es la causa más probable?
Por qué: que el contenido se vea demuestra que el HTML ya se pintó; lo que falta es el enchufe de eventos. Si el problema fuera de escape, verías el marcado como texto, no un botón mudo.
El servidor pinta “Hace 2 minutos” y el cliente calcula “Hace 3 minutos”. El marco de trabajo rehace esa parte del árbol. ¿Qué estás viendo?
Por qué: el desajuste no siempre lanza un aviso visible, y cuando lo hace ya pagaste el costo: se tiró el HTML útil y se volvió a calcular. La hora relativa es un caso clásico porque depende del reloj de cada máquina.
Mueves una librería de gráficas a un import() dinámico que solo se carga al abrir un panel. ¿Qué lograste?
Por qué: un import dinámico crea otro archivo que se pide en otro momento; el total de código que el usuario ejecuta no baja. Solo baja lo que la primera pantalla necesita, y eso es útil, pero no es lo mismo que borrar la dependencia.
Un catálogo de 4.000 productos se genera en el build. Cambias el precio de uno y no aparece hasta el siguiente despliegue. ¿Qué ataca esto sin volver a SSR puro?
Por qué: streaming cambia cómo se entrega una respuesta, no cuándo se generó el contenido; hidratar no actualiza datos que nunca se pidieron. La revalidación ataca la vejez del archivo, que es el problema real.
¿Qué pasa cuando el HTML lo escribe un desconocido?
Un comentario, un nombre de usuario, el HTML de un proveedor: en cuanto un texto ajeno entra a tu página, la pregunta deja de ser de estilo y se vuelve de fronteras. Aquí se decide qué puede leer quién y qué se ejecuta en tu nombre.
01Qué es un origen y por qué el navegador lo respeta
Un origen es la dirección completa del edificio donde vive una página: el protocolo (https o http), el dominio y el puerto, el número que distingue un servicio de otro en la misma máquina (si no se escribe, https usa el 443). Cambia cualquiera de los tres y es otro edificio, aunque el nombre se parezca.
La política de mismo origen es la regla de la portería: el código de una habitación no puede leer el contenido de una habitación de otro edificio. No puede leer su árbol de elementos, ni las respuestas de sus peticiones, ni las cookies con las que el sitio te reconoce. Puede mandarle una petición, pero no leer lo que vuelve. Esa distinción importa: la política no impide que te manden cosas, impide que las leas.
El ataque de secuencias de comandos entre sitios (XSS, cross-site scripting) no necesita entrar a tu servidor: le basta con que un texto suyo termine interpretado como código. Si tu página arma HTML pegando cadenas y el nombre que llega es una etiqueta con un manejador, el navegador no ve un nombre: ve una orden y la ejecuta. El escape es la defensa: los caracteres con significado en HTML se escriben como entidades. El signo < se convierte en <, el & en & y las comillas en su entidad, así que el navegador pinta el texto en lugar de obedecerlo. El escape depende del contexto: no es igual en un párrafo que en un atributo o una URL.
Escapar no es limpiar: es decidir en qué idioma se habla. Si pintas el texto crudo con textContent, el navegador lo trata como texto; con innerHTML, como HTML. La misma cadena es inofensiva en un lugar y peligrosa en el otro.
02Por qué sucede: la lista blanca y el huésped
La política de seguridad de contenido (CSP) es una cabecera que el servidor manda con la página: una lista blanca de dónde puede venir el código que se ejecuta. Si llega un script de un dominio que no está, el navegador no lo ejecuta. El problema es unsafe-inline, la instrucción que permite scripts y manejadores escritos dentro del propio HTML. Ahí vive casi todo el XSS moderno, porque el atacante no necesita un dominio: solo meter su código en tu página. Con unsafe-inline la lista blanca deja de cubrir el punto que más se ataca. Las alternativas son un nonce, un valor aleatorio por respuesta que autoriza solo tus bloques, o un hash (una huella del contenido).
Un iframe es una ventana dentro de la ventana: otro documento, con su propio origen, pintado en un rectángulo. El atributo sandbox le quita permisos al huésped: sin scripts, sin formularios, sin navegación, sin acceso al almacenamiento, salvo los que enumeres. Un iframe con sandbox y sin allow-same-origin recibe un origen opaco: el navegador lo trata como "null", un documento sin dirección, que no puede leer nada del sitio que lo contiene ni identificarse. Eso es lo que quieres para HTML de terceros. El detalle que se copia mal en todas partes: allow-scripts y allow-same-origin juntos anulan el sandbox. Con los dos, el marco ejecuta código y además es del mismo origen que la página; desde ahí alcanza el documento que lo contiene y se quita el atributo sandbox a sí mismo. El sandbox deja de existir en la primera línea de JavaScript.
Para hablar entre orígenes distintos está postMessage: pasar una nota por debajo de la puerta. El emisor llama a la ventana del otro marco con un mensaje y un targetOrigin; el receptor escucha y recibe un evento con data, origin y source. La nota no se lee si no la recoges, y el receptor no debe creerla. Tres puertas: el origen exacto, comparado contra una lista y nunca con includes ni startsWith, porque un dominio que empieza igual pasa ese filtro; event.source, porque otro marco del mismo origen también pasaría la primera; y un esquema estricto del contenido, campo por campo, con tipos, longitudes y valores permitidos.
¿Y el targetOrigin: '*'? En el emisor es un agujero si manda datos privados: cualquier documento que esté en ese marco los recibe. Pero si el que manda es el marco opaco, su origen 'null' no identifica nada, y el padre solo puede apoyarse en event.source y en el esquema. Y si mandas hacia un marco opaco, '*' es lo único que funciona: ningún texto nombra un origen 'null'. Ahí la seguridad está en no mandar secretos y en validar al recibir.
"null": no puede leer la página que lo contiene. La nota no se usa hasta pasar las tres validaciones, y ninguna sobra: cada una cubre un fallo distinto.03Cómo se ataca
La regla de oro es escapar donde el dato se vuelve interfaz, no limpiar después. Un texto inofensivo puede volverse peligroso en otro contexto; solo el renderizador sabe si está en un párrafo, un atributo o una URL.
Para la CSP, empieza con default-src 'none' y abre solo lo que la página necesita, primero en modo report-only para ver qué romperías. Para contenido de terceros, sandbox mínimo y nunca allow-scripts con allow-same-origin. Para los mensajes, valida en la frontera y usa el dato validado: nunca pases event.data a innerHTML.
// El iframe del editor avisa cuando terminó de cargar un documento. const ORIGEN_EDITOR = 'https://editor.ejemplo.mx'; window.addEventListener('message', (evento) => { // Puerta 1: ¿de quién viene? Comparación exacta contra la lista. if (evento.origin !== ORIGEN_EDITOR) return; // Puerta 2: ¿es el marco que esperamos? Otro marco del mismo // origen también pasaría la puerta 1. if (evento.source !== marcoEditor.contentWindow) return; // Puerta 3: ¿el contenido tiene la forma esperada? Se valida // campo por campo y se descarta todo lo que no encaje. const datos = evento.data; if (typeof datos !== 'object' || datos === null) return; if (datos.tipo !== 'documento-listo') return; if (typeof datos.id !== 'string' || datos.id.length > 64) return; if (!/^[a-z0-9-]+$/.test(datos.id)) return; // Solo pasa un identificador validado; nunca HTML del mensaje. cargarDocumento(datos.id); });
Fíjate en la última línea: el mensaje no se usa, se usa lo que sobrevivió a la validación. Esa diferencia es la que separa una frontera de un conducto.
04Cómo diagnosticarlo
La seguridad no se ve en la pantalla: se ve en las cabeceras, en el panel de seguridad y en el código que acepta datos de fuera.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| DevTools › Security | Si la página es segura, si hay contenido mixto y qué orígenes la cargan. | Contenido mixto: un script viaja sin cifrar y cualquiera en la red lo cambia. |
| DevTools › Network › cabeceras | La cabecera content-security-policy en la respuesta del documento. |
Sin cabecera no hay lista blanca; con unsafe-inline no cubre lo que más se ataca; en report-only no bloquea nada. |
Auditoría de innerHTML |
Cada uso de innerHTML o dangerouslySetInnerHTML con datos que vienen de fuera. |
Una veintena de usos sugiere escape a mano; los que quedan necesitan un sanitizador (un limpiador que deja solo el HTML permitido) y una prueba con carga maliciosa. |
Auditoría de postMessage |
Los addEventListener('message') y el handler completo. |
Sin comparación de origen, con includes, o con event.data dentro de innerHTML: el agujero está a la vista. |
Validar el origen y luego confiar en el contenido. El origen dice de qué edificio viene la nota; no dice que sea cierta ni que no traiga una etiqueta que tu página vaya a ejecutar.
“An origin is protocol, host and port; the path never counts.”
“allow-scripts plus allow-same-origin turns the sandbox off.”
“Validate origin, source and shape before touching a single field.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Compara https://app.ejemplo.mx con https://app.ejemplo.mx:8443. ¿Son el mismo origen?
Por qué: el origen son tres piezas y el puerto es una de ellas: el mismo servidor en otro puerto es otro origen para el navegador. La ruta, en cambio, nunca participa; /precios y /cuenta son el mismo origen.
El iframe de un proveedor lleva sandbox="allow-scripts allow-same-origin" porque, dicen, así “funciona bien”. ¿Qué pasa con esa combinación?
Por qué: el sandbox lo aplica el navegador mientras el atributo esté puesto; si el marco puede ejecutar JavaScript y además es del mismo origen, tiene permiso para editar el documento que lo contiene, incluido su propio atributo. Para HTML de terceros, una de las dos concesiones se queda fuera.
Tu CSP dice script-src 'self' 'unsafe-inline'. Un atacante logra inyectar una etiqueta script en un comentario. ¿Se ejecuta?
Por qué: el código en línea no tiene dominio que consultar, y por eso la lista blanca no lo alcanza: unsafe-inline es exactamente el permiso para ejecutarlo. Un nonce por respuesta o un hash del contenido son las formas de autorizar lo tuyo sin abrir la puerta a lo demás.
Recibes un mensaje del iframe del editor. Ya comprobaste event.origin y usas event.data.html con innerHTML. ¿Qué falta?
Por qué: el origen identifica el edificio, no el marco exacto ni la calidad del contenido; otro marco del mismo origen pasaría la primera puerta. Y innerHTML revive las etiquetas del mensaje, así que la validación de origen no sirve de nada si después el texto se interpreta como código.
¿Por qué el código funciona y aun así ya no se puede cambiar?
El rendimiento se arregla con infraestructura. Esto otro no: cuando el equipo crece, lo que se rompe es la coherencia. La pregunta deja de ser si la página carga y pasa a ser cuánto cuesta tocarla.
01Escalar usuarios y escalar gente
En una cocina con un solo cocinero, la sal puede estar en cualquier estante: él sabe dónde. Con quince, la sal tiene un lugar, una etiqueta y una regla: no sale de la cocina. Nadie cocina mejor por eso; cambió lo que hay que acordar para que el turno funcione.
Escalar usuarios es que más gente use lo mismo: se arregla con caché, con servidores, con índices. Escalar gente es que más manos toquen lo mismo: no se arregla con infraestructura, porque lo que se rompe no es el rendimiento, es la coherencia. Dos personas implementan dos formas de formatear una fecha, tres equipos tienen tres copias del mismo botón, y un cambio de precio toca siete archivos que nadie sabía que estaban conectados.
El criterio que ordena todo lo demás: el costo de cambiar algo debe ser proporcional a lo que cambia. Cambiar el color de un botón debería tocar un token y, como mucho, una revisión. Cambiar cómo se calcula un impuesto debería tocar el dominio y sus pruebas, no la vista. Cuando un cambio pequeño exige abrir doce archivos y pedir permiso a tres equipos, el problema no es el código: es el dibujo de las fronteras.
Los números ayudan a verlo: con 4 personas el acuerdo cabe en la cabeza; con 12, ya no. Un equipo de 12 suele tener tres o cuatro ritmos de cambio distintos, y cada uno merece una frontera propia.
02Fronteras, contratos y guardarraíles
Una frontera de módulo es una línea que dice qué puede saber una parte del código de la otra. Lo que las hace útiles es la dirección: las dependencias apuntan hacia dentro. La interfaz, lo que se ve, puede depender de la aplicación, los casos de uso; la aplicación puede depender del dominio, las reglas del negocio; el dominio no sabe que existe una pantalla. Si el dominio importa la vista, ya no puedes probarlo sin navegador, ni reusarlo en otro cliente, ni cambiar la vista sin miedo.
Los contratos tipados compartidos son esa frontera aplicada a los datos: una sola fuente de tipos entre la API (la puerta por la que la web pide datos) y la web, generada del esquema. Si el esquema es la verdad, el tipo se genera; si el tipo se escribe a mano, hay dos verdades y una se queda vieja. El error deja de aparecer en producción y aparece al compilar.
El monorepo, un solo repositorio con varios paquetes, no sirve por tener todo junto: sirve porque las reglas entre paquetes se verifican en el mismo build (la compilación). En repos separados también se puede, pero cada frontera cuesta una integración.
Un guardarraíl es una regla que el sistema hace cumplir. Una regla en un documento se olvida; una regla en CI, la integración continua que corre las comprobaciones en cada cambio, falla el build y no se puede fusionar. La diferencia no es la redacción: es la consecuencia. Una regla que no falla un build es una sugerencia. La etiqueta de la sal no cocina; solo evita que quince personas discutan dónde está.
El design system es un contrato, no una biblioteca de componentes. Tokens en vez de valores: decisiones con nombre, color, espacio, tipografía, para que el tema se cambie en un lugar. Props semánticas (la intención que recibe un componente) en vez de valores: variant="peligro" y no color="#9c3a2e", porque la intención se puede auditar y el valor se copia. Versionado y codemods, transformaciones automáticas que actualizan el código de los consumidores cuando la API cambia. Y adopción medida: porcentaje de pantallas que usan el componente, no número de descargas.
Los micro-frontends son varias aplicaciones pequeñas que se despliegan por separado y se componen en una página. Tienen sentido cuando hay equipos independientes que no pueden coordinar un despliegue común, con ritmos distintos y fronteras de negocio claras. Casi nunca son la primera respuesta porque multiplican lo que ya era difícil: duplican dependencias, obligan a versionar contratos entre equipos, reparten el estado y hacen que el rendimiento de la página dependa del peor fragmento. Si no puedes desplegar tu parte sin pedir permiso, ganas algo; si solo quieres ordenar carpetas, no.
03Cómo se ataca
Primero se dibuja el mapa de cambios: qué se modifica junto y con qué frecuencia. La frontera va donde cambia el ritmo, no donde queda bonito. Lo que cambia cada semana se separa de lo que cambia cada trimestre, aunque hoy vivan en la misma carpeta.
Después se eligen las reglas que se pueden verificar: prohibir imports, limitar dependencias entre paquetes, congelar el tamaño del bundle (el paquete de código que se descarga). Cada regla necesita un mensaje que diga qué hacer en su lugar, no solo que está prohibido. Y se mide el costo real: cuántos archivos toca un cambio típico y cuántas revisiones necesita.
// eslint.config.js — la regla vive en el repositorio y corre en CI. export default [ { files: ['apps/web/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': ['error', { patterns: [{ group: ['@acme/db', '@acme/db/*', '**/lib/db'], message: 'La web no puede hablar con la base de datos: ' + 'pasa por @acme/api. Si falta un dato, se añade al ' + 'contrato compartido, no al import.', }], }], }, }, ]; // Lo que ve quien lo intenta, en la terminal y en la revisión: // error La web no puede hablar con la base de datos: // pasa por @acme/api. // apps/web/src/precios.ts:3:1
El mensaje es la mitad de la regla. “Prohibido” enseña a rodear el límite; “pasa por el contrato” enseña la salida correcta. Con doce personas, la regla se lee muchas más veces de las que se discute.
04Cómo diagnosticarlo
Los síntomas de una arquitectura que ya no aguanta son medibles y casi nunca aparecen en un panel de rendimiento.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Tiempo hasta el primer PR | Cuánto tarda alguien nuevo en fusionar un cambio pequeño (un pull request), contando entorno y revisión. | Más de una semana apunta a un problema de arranque y de fronteras, no de talento. |
| Pull requests que rompen otra área | Cambios que revierten o que exigen arreglos fuera de su carpeta. | Es la medida directa del acoplamiento: si es frecuente, las fronteras son decorativas. |
| Duplicación medida | Cuántas copias del mismo componente o utilidad existen, buscando por nombre y por forma. | Tres botones con tres comportamientos distintos significan que el contrato no existe o no se adopta. |
| Acoplamiento | Cuántos imports entran y salen de cada paquete. | Un paquete con cuarenta entradas es un centro de gravedad: moverlo obliga a mover medio repo. |
| Reglas que fallan un build | Cuántas reglas de arquitectura existen y cuántas se ejecutan en CI. | Diez reglas escritas y dos automatizadas es una arquitectura de opiniones, no de hechos. |
Intenta borrar el paquete de la interfaz y compilar el dominio. Si no compila, la frontera está invertida; si compila y pasa sus pruebas, la frontera es real.
“If the domain imports the view, you cannot test the rules without a browser.”
“A rule that does not fail the build is a suggestion.”
“Micro-frontends buy independent deploys and charge you in coordination.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
El módulo de precios importa el componente de tabla “para reutilizar el formato”. ¿Qué se rompió?
Por qué: el formato de una tabla es una decisión de presentación; al importarla, las reglas de precios ya no se pueden probar sin el componente y el paquete deja de ser reutilizable fuera de esa pantalla. Reutilizar está bien cuando la dependencia apunta en la dirección correcta.
La wiki dice “la web no debe importar la base de datos”. Tres meses después hay nueve imports. ¿Qué faltó?
Por qué: una regla escrita depende de que alguien la recuerde en el momento exacto en que está apurado; una regla en CI convierte el olvido en un error visible antes de fusionar. La documentación explica el porqué, la automatización sostiene el qué.
Un componente recibe color="#9c3a2e" en vez de variant="peligro". ¿Por qué importa?
Por qué: con la intención (“peligro”) el sistema decide qué color toca y puede cambiarlo en un lugar; con el valor, la decisión queda repartida en cada llamada y el día que cambie el tono hay que buscar el hexadecimal por todo el repo.
Dos equipos despliegan cada semana por separado y hoy comparten un único despliegue que se bloquea mutuamente. ¿Es buen caso para micro-frontends?
Por qué: el caso que justifica la técnica es la imposibilidad de coordinar un despliegue común; ahí la independencia vale el precio. Con más fragmentos, el contrato visual compartido importa más, no menos: sin él la página se ve como tres productos distintos.
¿Cómo se entrega una función de IA sin que sea una caja negra?
Un modelo que responde bien en tu máquina no es una función: es una demostración. Entregarla exige estados, medición, un portón humano y un interruptor para apagarla.
01Qué es entregar una función de IA
Contratas a un practicante brillante y le das las llaves del almacén. Escribe rápido, casi siempre acierta, y cuando se equivoca no lo dice: contesta con la misma seguridad. Lo que necesitas no es uno mejor, es verlo trabajar, una lista de comprobación, alguien que firme lo irreversible y un interruptor para pararlo.
Un token es el trozo mínimo de texto con el que trabaja un modelo: en español, más o menos una palabra corta o parte de una palabra larga. El streaming de tokens es recibir esos trozos conforme se producen en lugar de esperar la respuesta completa. Para la interfaz cambia todo: a los 300 ms ya hay algo en pantalla, y la persona puede cancelar antes de gastar veinte segundos y el costo de la respuesta entera.
Una función de IA tiene más estados que “cargando” y “listo”. Esperando: la petición salió y no hay primer token. Generando: hay texto parcial. Parcial: se cortó por límite de tokens o por cancelación, y lo que hay puede servir. Error recuperable: reintentar tiene sentido. Límite alcanzado: cuota o longitud. Rechazado por el humano: el texto existe pero no se usa. Si el código solo distingue dos, los demás se muestran como error, y un rechazo se ve igual que una caída.
La latencia y el costo son parte de la experiencia, no detalles de operación. La latencia se mide en p95, el tiempo que tarda el 95% de las peticiones: el promedio esconde la cola, que es donde la gente abandona. El costo se mide por generación y por usuario. Mostrar el progreso y el costo estimado no es transparencia decorativa: es lo que permite decidir si vale la pena esperar.
Y la IA no puede ser la única salida. Si el modelo falla, la persona tiene que poder terminar el trabajo a mano o hablar con alguien. Un fallback (una salida de respaldo) a humano no es una derrota técnica: es la diferencia entre una función que se degrada y un incidente.
02Por qué sucede: sin medición es una demo
Un modelo no es determinista: la misma entrada puede dar salidas distintas, y “se siente mejor” no es una medida. La evaluación es un conjunto de casos con resultado conocido y un criterio explícito de qué es aceptable. Sin ese conjunto solo tienes opiniones sobre las últimas cinco respuestas que viste; con él, un cambio de prompt se compara contra la versión actual y el número puede empeorar.
Los guardarraíles tienen dos lados. Lo que no se manda: datos personales, secretos, credenciales; se filtran antes de la petición, no después. Lo que se valida al recibir: que la salida tenga la forma esperada, longitud máxima, enlaces permitidos, HTML escapado. El modelo es una fuente externa: su respuesta se trata como texto de un desconocido, aunque el desconocido seas tú.
Y las acciones irreversibles pasan por una persona. El modelo propone; el humano firma. Ese portón es también un dato: cuántas propuestas se rechazan dice más de la calidad que cualquier encuesta.
Una función de IA sin medición es una demo, y una demo sin fallback es un incidente. Las dos frases apuntan al mismo lugar: si no sabes cómo se está comportando ni qué pasa cuando no responde, no tienes una función, tienes una apuesta.
03Cómo se ataca
Modela los estados como una unión discriminada: un tipo con un campo que dice cuál es y datos propios de cada caso. Así el compilador obliga a manejar todos, incluidos el rechazo y el límite. Si el estado es un texto libre con tres banderas, siempre hay una combinación que nadie dibujó.
Para medir, separa RUM de laboratorio. RUM son métricas de usuarios reales, tomadas del navegador de quien usa el producto: ruidosas, pero verdad. El laboratorio es controlado y reproducible, pero de un solo dispositivo. Las dos sirven; confundirlas hace que un portátil rápido decida por todos.
El registro de errores (error tracking) necesita contexto: identificador de la solicitud, modelo, versión del prompt (la instrucción que se le da al modelo), ruta. Sin eso, “falló la IA” no se puede reproducir. Las trazas muestran el camino completo, de la API (la puerta por la que la web pide datos) a la cola y al modelo; sin traza no sabes en qué salto se perdió el tiempo. Y los feature flags (interruptores que se activan sin desplegar) permiten apagar en caliente: un flag que no se evalúa igual en servidor y cliente produce dos versiones de la misma pantalla, así que también se prueba.
// El estado de una generación: cada caso con sus datos, // nada opcional y nada que sobre. type Motivo = 'limite' | 'cancelado'; type Generacion = | { estado: 'esperando'; id: string } | { estado: 'generando'; id: string; texto: string } | { estado: 'parcial'; id: string; texto: string; motivo: Motivo } | { estado: 'error'; id: string; causa: string; reintentar: boolean } | { estado: 'rechazado'; id: string; texto: string; firma: string } | { estado: 'aceptado'; id: string; texto: string; firma: string }; // El compilador exige un caso por estado. Si mañana se // agrega 'revisando', esta función deja de compilar hasta // que alguien decida qué se dibuja en la pantalla. function vista(g: Generacion) { switch (g.estado) { case 'esperando': return <Espera />; case 'generando': return <Texto texto={g.texto} />; case 'parcial': return <Texto texto={g.texto} corte />; case 'error': return <Error causa={g.causa} reintentar={g.reintentar} />; case 'rechazado': return <Rechazo texto={g.texto} firma={g.firma} />; case 'aceptado': return <Entrega texto={g.texto} />; } }
La unión hace visible lo que el producto suele esconder: que “rechazado” no es “error”. Si los dos comparten pantalla, el usuario aprende a ignorar los dos. El practicante no mejora con un reglamento; mejora cuando el reglamento se ejecuta en cada entrega.
04Cómo diagnosticarlo
Una función de IA se diagnostica como cualquier otra, con una diferencia: hay que mirar también lo que el modelo no respondió.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Logs de error con contexto | Identificador de solicitud, modelo, versión del prompt y ruta en cada fallo. | Si solo dice “falló la IA”, no se puede reproducir; con el identificador se reconstruye la traza completa. |
| RUM por ruta | Latencia y errores de usuarios reales, separados por pantalla y dispositivo. | Una ruta con p95 de 8 s se ve aunque el promedio global esté bien; el promedio la esconde. |
| Tasa de cancelación | Porcentaje de generaciones canceladas y en qué estado se cancelaron. | Mucha cancelación antes del primer token es un problema de espera percibida, no del modelo. |
| Latencia p95 del modelo | La cola de la distribución, comparada por modelo y por tamaño de entrada. | Un p95 que triplica la mediana significa que una de cada veinte personas espera el triple; esa es la que se va. |
| Tasa de rechazadas | Cuántas respuestas descarta la persona que revisa y por qué motivo. | Si sube, el problema es de calidad o de criterio; si es cero, probablemente nadie está revisando. |
“If you cannot measure it, it is a demo.”
“Every AI feature needs a state for the human saying no.”
“A feature flag is how you turn it off without a deploy.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Tu componente solo distingue “cargando” y “listo”. El modelo corta por límite de tokens y devuelve texto a medias. ¿Qué muestra la interfaz?
Por qué: el estado parcial existe aunque el código no lo nombre, y al no tener su propia pantalla se confunde con el éxito. El usuario actúa sobre una respuesta truncada creyendo que está entera.
¿Por qué conviene poder cancelar mientras el modelo sigue generando?
Por qué: la generación se cobra por token producido, no por token leído, así que cada segundo de más es costo real. Cancelar también evita que la interfaz se llene de texto que nadie pidió y que tapa el siguiente intento.
Un cambio de prompt “se siente mejor”. ¿Cómo decides si sale a producción?
Por qué: sin casos fijos solo mides los ejemplos que te acuerdas de probar, y esos son justo los que ya sabías que funcionaban. El conjunto convierte “se siente mejor” en una afirmación que puede fallar.
El modelo empieza a responder con un p95 de 40 segundos. ¿Qué haces primero?
Por qué: primero se corta el daño y se mantiene la tarea posible por otra vía; investigar puede esperar treinta minutos, la persona que está intentando trabajar no. Subir el timeout solo alarga la espera, y cambiar de modelo sin medir mezcla dos causas en el mismo incidente.
¿Cuántas pruebas hay que escribir para dormir tranquilo?
Una prueba es una frase que la computadora comprueba cada vez que alguien toca el código. La pregunta no es cuántas necesitas, sino cuáles puedes correr en el tiempo que la gente está dispuesta a esperar.
01Qué es una prueba y para qué existe
Una prueba es una frase que la computadora comprueba sola: prepara un caso, ejecuta el código y compara el resultado con el esperado. “Pagar 300 con un cupón de 50 deja 250” se ejecuta en cada cambio, sin que nadie tenga que acordarse.
Una prueba no demuestra que el código esté bien: demuestra que un comportamiento que te importaba sigue igual después de tu cambio. El código se rompe porque alguien lo cambió para arreglar otra cosa.
La cobertura mide qué líneas se ejecutaron, no qué se comprobó. Un archivo con 100% de cobertura y ninguna aserción (ninguna comparación con el resultado esperado) se ve impecable y no detecta nada.
La suite es la lista del piloto antes de despegar: doce puntos, cuatro minutos, en el mismo orden. No vuela el avión; hace que volver a volar mañana sea una decisión y no una apuesta. Si un punto fallara al azar una de cada diez, el piloto aprendería a saltárselo.
No compra corrección: compra el derecho a cambiar el código sin miedo. Un equipo sin pruebas no es valiente, es lento.
02Por qué sucede: la pirámide y las pruebas que mienten
La pirámide de pruebas reparte el esfuerzo: mucha base de pruebas unitarias (una función o un módulo, sin navegador), una capa media de componente (una pieza de interfaz montada con su DOM) y una punta de pruebas E2E (end to end: la aplicación completa en un navegador real). La versión honesta no es una ley, es una proporción: del orden de 70% abajo, 20% en medio y 10% arriba. La punta no debe comerse el presupuesto.
Probar el dominio puro es lo que más rinde: funciones que reciben datos y devuelven datos, como un total, un permiso o una fecha. No tocan DOM, red ni reloj, corren en milisegundos y no dan falsos rojos: fallan por la regla, no por un selector. Cuando una se pone roja, el mensaje dice qué fecha y qué zona fallaron.
En componente se prueba lo que la persona ve y hace: que el botón tenga nombre accesible, que el error aparezca junto al campo, que Escape cierre el diálogo. Se monta la pieza con su DOM y cada prueba tarda decenas o cientos de milisegundos.
En E2E se prueba el viaje completo: entrar, filtrar, pagar, ver el comprobante. Es lo único que junta red, rutas y despliegue, y también lo más caro de mantener; un E2E que falla por un aviso de cookies no dice nada del producto. La regresión visual compara capturas y detecta lo que el DOM no ve, como un texto encimado.
Lo que no conviene probar: valores exactos de CSS, funciones privadas, cuántas veces se llamó un hook, el HTML completo de una pantalla. Si se rompe al renombrar una variable y no al cambiar el comportamiento, mide tu código.
Las pruebas inestables (flaky) pasan a veces y fallan a veces sin cambios. Las causas se repiten: tiempo (esperar con setTimeout, el reloj real, animaciones a medias), red (peticiones de verdad, respuestas en otro orden), orden (una prueba deja datos que otra espera), datos compartidos y azar sin semilla. Son peores que no tener pruebas: la que no existe no promete nada; la inestable enseña a repetir hasta el verde, y ese hábito repite también el rojo real.
fecha_entrega y pasa. La prueba de contrato habla con el esquema o la respuesta vigente, y por eso el renombre se convierte en un build rojo.03Cómo se ataca: dominio, contrato, datos y presupuesto
El primer movimiento es sacar el cálculo del componente. Si la regla de la ventana de fechas vive en un hook que lee el reloj, solo se prueba montando la pantalla; si vive en una función que recibe el instante y la zona, se prueba en milisegundos y se reutiliza en el servidor. Eso es inyectar dependencias.
// ventana.js — dominio puro: sin DOM, sin red, sin reloj. export function diaLocal(instante, zona) { return new Intl.DateTimeFormat('en-CA', { timeZone: zona, year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date(instante)); // YYYY-MM-DD } export function sumarDias(fecha, dias, zona) { const [a, m, d] = diaLocal(fecha, zona) .split('-').map(Number); // Se suma sobre el calendario, no sobre ms: // 24 h no siempre son un día (el 3 de noviembre // en Nueva York dura 25; el 10 de marzo, 23). const t = Date.UTC(a, m - 1, d + dias, 12); return new Date(t).toISOString().slice(0, 10); } // ventana.test.js — los casos borde son el motivo. import { describe, expect, test } from 'vitest'; import { diaLocal, sumarDias } from './ventana'; describe('diaLocal', () => { test('cruza la medianoche de la zona, no UTC', () => { // 02:30 UTC del 11 son las 20:30 del 10 en CDMX. expect( diaLocal('2024-03-11T02:30:00Z', 'America/Mexico_City'), ).toBe('2024-03-10'); }); test('Tijuana y el centro no ven el mismo día', () => { // La frontera sí cambia; el centro no, desde 2022. expect( diaLocal('2024-11-04T07:30:00Z', 'America/Tijuana'), ).toBe('2024-11-03'); expect( diaLocal('2024-11-04T07:30:00Z', 'America/Mexico_City'), ).toBe('2024-11-04'); }); }); test('sumar un día no son 24 horas', () => { // 23:30 del 9 de marzo en Nueva York: // 24 h después del instante ya es el 11. expect( sumarDias('2024-03-10T04:30:00Z', 1, 'America/New_York'), ).toBe('2024-03-10'); }); // La versión mala: pasa con una función que devuelve basura. test('sumarDias no truena', () => { expect(() => sumarDias('2024-03-10T04:30:00Z', 1, 'America/New_York'), ).not.toThrow(); });
Esa última prueba es verde aunque sumarDias devuelva la misma fecha: solo comprueba que no lanza una excepción. “No truena” describe un accidente, no un comportamiento.
El segundo frente es el contrato entre frontend y API. El backend renombra fecha_entrega a delivery_date, cambia un null por una cadena vacía o manda otro formato, y te enteras en producción. TypeScript no cruza la red: los tipos se borran al compilar. Una prueba de contrato valida la respuesta real (o el esquema publicado) con un esquema en la frontera, tipos generados desde OpenAPI o un contrato del consumidor con Pact. Un mock escrito a mano conserva el nombre viejo; el contrato falla cuando el otro lado cambia.
En regresión visual el problema es la tolerancia. Demasiado estricta, cualquier diferencia de antialiasing o de fuente produce cientos de imágenes y el equipo aprende a aprobar sin mirar. Demasiado laxa, un umbral del 5% deja pasar un botón desplazado 40 px en una página de 2.000. Se arregla controlando la entrada: misma ventana, fuentes del repositorio, animaciones apagadas, reloj congelado y máscaras sobre avatares, anuncios y horas relativas.
Los datos de prueba necesitan aislamiento. Una base por worker (cada proceso con su esquema, desde una plantilla) evita que dos pruebas se pisen; semillas deterministas (ids fijos, azar con semilla, reloj congelado) hacen que el mismo caso dé el mismo resultado. Si dos pruebas comparten el mismo correo o la misma fila, el orden decide.
Y está el presupuesto. La suite que corre al guardar debería tardar un par de segundos; la de cada pull request, menos de diez minutos; la completa, de noche. Si tarda cuarenta, la gente la corre el viernes y los errores llegan el lunes. Se sostiene dividiendo por proyectos y repartiendo en procesos.
04Cómo diagnosticarlo
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
--repeat / --rerun-each |
La misma suite 20 veces, con orden aleatorio. | Falla en la corrida 7 y pasa en la 8: inestable confirmada; si nunca falla, el azar no era la causa. |
| Tiempo por archivo | El reporte detallado o el XML de la CI. | 40 s en un archivo y 2 s en el resto: un E2E disfrazado de unitario. |
| Cobertura (v8, Istanbul) | Líneas cubiertas y aserciones por archivo. | 92% de líneas con pruebas que no comparan nada: cobertura alta, señal nula. |
| Aserciones ausentes | expect-expect de ESLint o mutación con Stryker. |
Pasa una prueba que solo llama a la función; con mutación, el mutante sobrevive. |
| Orden aleatorio | --sequence.shuffle o el orden por defecto del runner. |
Verde en orden alfabético y rojo al mezclar: estado compartido entre archivos. |
| Reintentos en CI | Cuántas pruebas pasan solo al segundo intento. | Con retry: 2 global, el reporte dice cero y nadie sabe cuántas fueron. |
Si el 80% del tiempo está en cinco archivos E2E, no faltan pruebas: sobran E2E y falta dominio. Y si el reporte lleva semanas en verde sin que nadie haya visto un rojo, conviene preguntarse si las pruebas miran algo.
“Most of our tests live at the domain level: pure functions, no browser, milliseconds per test.”
“We run contract tests in CI, so a renamed backend field fails the build instead of production.”
“Flaky tests get quarantined within a day; a suite nobody trusts is worse than no suite.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
¿Cuál de estas pruebas probablemente siga siendo útil dentro de un año?
Por qué: una regla de negocio sobrevive a los rediseños; el número de efectos y el tamaño en píxeles cambian con cada refactor. Esa prueba se rompe por decisiones de implementación, no porque el producto falle.
La suite está en verde, pero dos pruebas fallan una de cada diez corridas. ¿Cuál es el daño principal?
Por qué: el tiempo se recupera; la costumbre no. Cuando “vuelve a correrlo” es la respuesta normal, el semáforo deja de informar y el equipo pierde el único canal que le avisaba de una regresión.
El backend renombra fecha_entrega a delivery_date. ¿Qué lo detecta antes de producción?
Por qué: el mock guardado a mano conserva el nombre viejo y el snapshot también: los dos te dan la razón. Solo algo que hable con el contrato vigente nota que el campo desapareció, y por eso la prueba se ejecuta contra el esquema publicado o contra el servidor de pruebas.
¿Qué presupuesto de tiempo es razonable para la suite que corre en cada pull request?
Por qué: el presupuesto no es una preferencia: es la condición para que la suite se use. Pasados unos minutos, la gente empuja y espera; pasada la hora, la corre al día siguiente y el error llega cuando ya hay tres cambios encima.
¿Por qué el 15% de tus usuarios no puede usar lo que construiste?
La accesibilidad no es una capa que se pinta al final: es una decisión sobre el teclado, el foco y el texto. Y el texto, cuando se traduce, crece.
01Qué es accesibilidad y por qué es arquitectura
La accesibilidad es que la interfaz se pueda usar sin ratón, sin ver bien, sin oír y con poca memoria de trabajo. Se recorre con Tab, se entiende con un lector de pantalla (el programa que lee en voz alta lo que hay en pantalla) y tiene contraste suficiente.
La Organización Mundial de la Salud estima que ~16% de la población mundial vive con una discapacidad significativa. La cuenta real es mayor porque incluye lo temporal y lo situacional: el brazo enyesado, el sol en la pantalla, el bebé en el otro brazo. Casi todos pasamos por ahí.
Si la rampa de un edificio se dibuja después, termina en la puerta de servicio. En el plano es una línea; agregada al final, obliga a romper la escalera. Con la interfaz pasa igual: pensada con los componentes, un diálogo nace con su foco.
Y eso es arquitectura, no buena voluntad. El orden de tabulación sigue el orden del DOM (el árbol de elementos), así que una tarjeta que se ve arriba pero se escribió al final se recorre al revés; un diálogo de capas flotantes no tiene dónde encerrar el foco.
Un aria-label no arregla un div que no recibe foco, ni un texto alternativo un formulario que solo se envía con ratón. ARIA describe lo que existe; no crea comportamiento.
02Por qué sucede: foco, lectores, contraste y textos que crecen
El foco es el lugar que recibe lo que tecleas; se mueve con Tab y Shift+Tab. El orden lo decide el DOM: un tabindex="2" a mano rompe el recorrido. El foco tiene que verse; quitar el contorno sin reemplazarlo deja a quien navega con teclado a ciegas. En un diálogo, el foco entra al abrir, no se escapa y vuelve al botón al cerrar.
Un lector de pantalla anuncia nombre, rol y estado: “botón, Guardar cambios”. El nombre sale del texto visible, de un label asociado o de aria-label. Si el aria-label contradice el texto visible, el daño es peor que no ponerlo: quien usa control por voz dice “clic en Guardar” y el botón se llama “Enviar formulario”. Un div no responde a Enter.
Para avisar sin mover el foco están las regiones vivas: un contenedor con aria-live="polite" al que le cambias el texto y el lector lo lee al terminar la frase en curso. “3 resultados” tras filtrar. La región debe existir antes del cambio; si la insertas con el mensaje, muchos lectores no la anuncian.
El contraste se mide, no se aprecia: 4.5:1 para texto normal, 3:1 para texto grande y para bordes de campos y anillo de foco. A ojo, un verde oliva parece perfecto y da 3.8:1; el gris del placeholder suele quedarse en 2.5:1. Hay que revisar hover y modo oscuro.
En un formulario, la etiqueta va asociada al campo con for e id; el placeholder no es etiqueta porque desaparece al escribir. El error se anuncia, no solo se pinta: el campo lo referencia con aria-describedby, el resumen usa role="alert" y el mensaje dice qué corregir: “El código postal debe tener 5 dígitos”, no “Error”. Si el único indicador es rojo, no todos lo ven.
La internacionalización empieza por no concatenar. Pegar “Eliminar”, el número y “elementos” funciona en español y en inglés, pero en alemán el verbo va al final y en japonés el orden es otro; además el género cambia: “usuario nuevo”, “cuenta nueva”. El texto completo, con su hueco, es la unidad de traducción. Los plurales tampoco son una comparación con 1: el polaco distingue uno, pocos y muchos; el árabe tiene cero, uno, dos, pocos, muchos y otros. Eso lo resuelve Intl.PluralRules.
Fechas y números siguen la configuración regional: 03/04/2024 es 3 de abril en México y 4 de marzo en Estados Unidos. Se formatean con Intl.DateTimeFormat y Intl.NumberFormat. En idiomas de derecha a izquierda (RTL) el documento lleva dir="rtl" y los márgenes usan propiedades lógicas; las flechas de “atrás” se reflejan, los números no.
Y está el texto que crece: el alemán ocupa del orden de 30% más y sus palabras compuestas no se parten solas. Un botón de 120 px con “Guardar” se rompe con “Änderungen speichern”: el texto se sale o se corta. Se prueba con seudolocalización, 200% de zoom y 320 px. La otra fuente de bugs son las zonas horarias: guarda instantes UTC y formatea con la zona explícita; “mañana a las 9” no es sumar 24 horas, porque en el cambio de horario el día dura 23 o 25.
03Cómo se ataca: componentes, mensajes y pruebas
El orden es semántica primero: un button es un botón, un label una etiqueta, un dialog un diálogo. Después, teclado y foco; ARIA solo cuando el HTML no alcanza. Se sostiene haciendo que el componente correcto sea el fácil: un Dialog que encierra el foco y lo devuelve, un Field que asocia etiqueta y error.
Para los textos, un catálogo ICU fuera del código, la configuración regional del perfil y un respaldo explícito. El lang del documento (document.documentElement.lang) cambia con el idioma —una página en español que declare lang="en" hace que el lector pronuncie mal— y el dir con la dirección. Las fechas se guardan en UTC y se formatean con la zona de quien las lee.
El presupuesto de pruebas incluye tres guardarraíles baratos: una seudolocalización que alarga las cadenas, una revisión con axe y un recorrido de teclado en los diálogos. No sustituyen a una persona que use lector, pero atrapan lo que se rompe en cada pull request.
// mensajes.js — el plural y el orden los decide el idioma. const catalogos = { es: { one: '# elemento', other: '# elementos' }, en: { one: '# item', other: '# items' }, pl: { one: '# element', few: '# elementy', many: '# elementów', other: '# elementu', }, }; export function contar(n, locale) { // Intl.PluralRules elige la categoría; el texto // lo pone el catálogo de cada idioma. const categoria = new Intl.PluralRules(locale).select(n); const catalogo = catalogos[locale] || catalogos.en; const plantilla = catalogo[categoria] || catalogo.other; const numero = new Intl.NumberFormat(locale).format(n); return plantilla.replace('#', numero); } contar(1, 'es'); // "1 elemento" contar(3, 'es'); // "3 elementos" contar(3, 'pl'); // "3 elementy" contar(5, 'pl'); // "5 elementów" // La fecha se guarda en UTC y se muestra en la zona // de quien la lee. export function cita(instante, locale, zona) { return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short', timeZone: zona, // sin esto manda el navegador }).format(new Date(instante)); } cita('2024-03-10T15:00:00Z', 'es-MX', 'America/Mexico_City'); // "10 de marzo de 2024 a las 9:00 a.m." cita('2024-03-10T15:00:00Z', 'de-DE', 'Europe/Berlin'); // "10. März 2024 um 16:00"
En ICU el mismo mensaje se escribe {n, plural, one {# elemento} other {# elementos}} y lo compila FormatJS; arriba está la pieza que importa, la categoría.
04Cómo diagnosticarlo
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| axe DevTools / Lighthouse | Nombre accesible, contraste, etiquetas. | “Button has no accessible name” en los iconos. |
| Solo teclado | Tab, Shift+Tab, Enter, Espacio, Escape. | El foco desaparece, salta al pie o queda atrapado. |
| Lector de pantalla | Nombre, rol, estado y anuncios tras una acción. | Dice “botón” y nada más; el error no se anuncia. |
| Medidor de contraste | El ratio real, también en hover y foco. | El texto da 3.9:1 y el borde 2.2:1: se ve, no pasa. |
lang y dir |
El idioma y la dirección del documento. | Página en español con lang="en": el lector pronuncia mal. |
| Locale y zona forzados | TZ, locale y timezoneId. |
La cita de las 09:00 se ve a las 15:00: el runner corre en UTC. |
| Zoom 200% / 320 px | Reflujo y scroll horizontal. | El botón parte la palabra o aparece scroll horizontal. |
Ninguna herramienta ve la experiencia completa. La auditoría automática atrapa entre un tercio y la mitad; el resto aparece al intentar terminar una tarea con teclado o con lector.
“Keyboard and focus are part of the component API: our dialog traps focus and returns it to the trigger.”
“Every string goes through ICU messages, so word order and plurals are the translator's call, not a concatenation.”
“We format dates with an explicit time zone and run tests with locale and TZ forced, so the runner doesn't decide.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Abres un diálogo con el botón “Editar dirección”. ¿Qué debe pasar con el foco?
Por qué: si el foco se queda atrás, el primer Tab recorre la página de fondo y la persona no sabe que hay un diálogo. Al cerrar, devolverlo al disparador evita reiniciar el recorrido desde el principio.
Un botón visible dice “Pagar” y lleva aria-label="Enviar". ¿Cuál es el problema?
Por qué: quien usa control por voz dice lo que ve: “clic en Pagar”. Si el nombre accesible es otro, el comando no encuentra el botón. La regla es que el nombre contenga el texto visible, no que lo reemplace.
¿Por qué "Quedan " + n + " días" falla al traducir?
Por qué: cada idioma coloca el número donde quiere y tiene sus propias categorías de plural: el polaco separa pocos de muchos y el árabe tiene cero y dos. La cadena completa, con su hueco, es lo que se traduce.
Guardas 2024-03-10T15:00:00Z y en Ciudad de México se ve “09:00”. ¿Qué falta para que en Berlín se vea “16:00”?
Por qué: el instante UTC ya es correcto; lo que falta es decir en qué zona se dibuja. Una resta fija de horas funciona hasta el cambio de horario, y guardar la hora local pierde el instante cuando la persona viaja o cuando hay dos horarios de verano distintos.
El mapa completo
Las catorce lecciones anteriores son una parte de la escalabilidad frontend, no el todo. Esta es la lista honesta de lo que existe, con una marca de qué mirar primero para esta entrevista y qué puedes dejar para después.
Las catorce lecciones cubren cómo se rompe el navegador —datos, red, estado, fronteras—, más la parte de calidad y la de accesibilidad e idioma. Lo que queda fuera es especializado: colaboración multiusuario con CRDT, WebGL, y el detalle fino de operación (trazas distribuidas). Para esta vacante, no es lo que decide.
Familias de problemas
- Listas y tablas masivas, virtualización, paginación
- Filtrado, ordenamiento y búsqueda a escala
- Gráficas con presupuesto de puntos y agregación
- Media pesada: video, canvas, WebGL, imágenes enormes
- Cascadas de peticiones, N+1, agrupación, precarga
- Caché de datos, invalidación, stale-while-revalidate
- Paginación por cursor frente a por desplazamiento
- Tiempo real: WebSocket, SSE, reconexión, orden de eventos
- Offline y sincronización con conflictos
- Colas, workers, backpressure, idempotencia
- Agregación al escribir y retención por capas
- Contratos de API que deciden si la interfaz puede funcionar
- Re-render, identidad de objetos, selectores, contexto partido
- Máquinas de estado y uniones discriminadas
- Deshacer/rehacer (patrón comando) en editores
- Colaboración multiusuario: CRDT y transformación operacional
- Normalización de entidades y estado derivado
- SSR, SSG, ISR, streaming, hidratación parcial, islas
- Server Components y la frontera servidor/cliente
- Edge rendering y personalización por región
- Presupuesto de bundle y estrategia de carga
- Core Web Vitals: LCP, INP, CLS
- Laboratorio (Lighthouse) frente a campo (RUM)
- Fuentes, imágenes y precarga
- Fugas de memoria: intervalos, suscripciones, iframes
- Presupuesto de rendimiento verificado en CI
- XSS, escape, CSP, cabeceras, confianza cero en el cliente
- Aislamiento de contenido de terceros: iframe, sandbox, postMessage
- Dónde viven los tokens y por qué no en el almacenamiento del navegador
- Cadena de suministro de dependencias
- Capas, fronteras, dirección de dependencias
- Monorepo, paquetes, contratos tipados, generación de tipos
- Design systems: tokens, versionado, codemods, adopción
- Micro-frontends: cuándo sí y por qué casi nunca primero
- Proceso: ADRs, RFCs, propiedad de código, tronco común
- Pirámide de pruebas y qué se prueba en cada nivel
- Pruebas de contrato entre frontend y API
- Regresión visual y su tolerancia
- Pruebas inestables (flaky): causas y cómo se erradican
- Datos de prueba y aislamiento entre pruebas
- RUM, error tracking con contexto, trazas
- Feature flags y despliegue progresivo
- Experimentación y medición de una función
- Paneles que responden "¿está roto ahora mismo?"
- Accesibilidad como sistema, no como parche final
- Teclado, foco, regiones vivas, contraste
- i18n: plurales (ICU), formatos, RTL, longitud variable del texto
- Zonas horarias y horario de verano
- Streaming, cancelación, estados intermedios, costo
- Evaluación con casos conocidos y tasa de rechazo
- Fallback a humano y guardarraíles de datos
Orden de estudio para esta vacante
| Prioridad | Qué | Por qué |
|---|---|---|
| 1 | Datos, red y estado (lecciones 03–08) | Es el 80% de las preguntas de un frontend senior en producto. |
| 2 | Entrega y fronteras (09, 10) | Adtech vive de contenido de terceros: el iframe y la CSP salen seguro. |
| 3 | Arquitectura y organización (11) | Es la ronda del hiring manager, aunque se pregunte en forma de "¿cómo trabajas con otros?". |
| 4 | Observabilidad (12) | Diferencia a quien ha operado algo de quien solo ha construido. |
| 5 | CRDT, transformación operacional, WebGL, trazas distribuidas | Interesante y especializado. Solo si el puesto es un editor colaborativo o un visor 3D. |
en una entrevista de frontend de producto: cómo implementar un balanceador, Kubernetes, sharding de base de datos, o teoría de compiladores. Si te preguntan por eso, es para ver si finges.
Para cada familia, pregúntate: ¿puedo nombrar un caso real donde esto se rompió, cómo lo detecté y qué cambié? Si no puedes, no la sabes todavía — la has leído. La diferencia entre leer y saber es una historia propia por familia.
Cierra el curso
Tres preguntas de cierre.
¿Cuál de estas familias no se puede resolver solo en el navegador?
Por qué: la caché de una persona no sirve a otra. Ese eje se resuelve en el servidor y en el borde; el frontend solo decide qué pedir y qué reutilizar.
Si tuvieras una sola tarde para preparar una entrevista de frontend senior, ¿qué familia dejarías fuera?
Por qué: las dos primeras salen en cualquier entrevista de producto; la tercera solo si el puesto es un editor colaborativo. Preparar por probabilidad, no por interés.
¿Qué distingue "haber leído" de "saber" una familia de problemas?
Por qué: el nombre de una herramienta se olvida; una historia con número de antes y después no. Y es exactamente lo que el entrevistador intenta extraer con la segunda pregunta.