Por qué bloquear el hilo arruina la interacción aunque la página ya esté pintada
El recorrido completo de un clic desde el sistema operativo hasta el píxel, dónde se cuela la espera cuando el hilo está ocupado, y por qué una página que se ve terminada puede estar completamente muerta.
Hay un estado de una página web que produce más frustración que cualquier otro: la página se ve completa, con su texto, sus imágenes y sus botones, y no responde a nada. El usuario pulsa, no pasa nada, vuelve a pulsar, y a los dos segundos ocurren las dos acciones seguidas. Esa desconexión entre lo que se ve y lo que funciona es la consecuencia directa de un hilo principal ocupado, y entender el camino que recorre un clic explica exactamente dónde se pierde el tiempo.
- Trazar el recorrido de un clic desde el hardware hasta el píxel actualizado.
- Identificar los tres puntos donde una tarea larga inserta espera.
- Explicar por qué el pintado inicial puede completarse antes que la interactividad.
- Reconocer el patrón de la doble pulsación y qué revela.
El recorrido de un clic
Cuando el usuario toca la pantalla, ocurre esto:
Uno. El sistema operativo entrega el evento al navegador. Llega al proceso del navegador, que no es el que ejecuta tu JavaScript.
Dos. El proceso del navegador decide a qué documento corresponde y lo pone en la cola del proceso de renderizado correspondiente, en el hilo principal.
Tres. El hilo principal termina la tarea que estuviera ejecutando. Aquí está la primera espera.
Cuatro. El hilo ejecuta la tarea del evento: todos los oyentes registrados, en orden de captura y burbujeo. Aquí está la segunda espera, si los oyentes hacen mucho trabajo.
Cinco. Terminados los oyentes, se vacían las microtareas pendientes.
Seis. El navegador decide si toca actualizar el fotograma. Si hay cambios pendientes, ejecuta recálculo de estilo, layout, pintura y composición. Aquí está la tercera espera, si el cambio que hiciste obliga a un layout caro.
Siete. El compositor presenta el fotograma. El usuario ve el resultado.
Los pasos tres, cuatro y seis son exactamente las tres partes del INP, y por eso esa métrica es tan diagnosticable: cada parte tiene sus propias causas y sus propias soluciones. Lo verás en detalle en las tres partes del INP.
Por qué la página se ve terminada y no responde
La clave está en que pintar y responder son dos capacidades distintas, y la primera puede completarse mucho antes que la segunda.
El pintado de la página inicial depende del HTML y del CSS. En cuanto el navegador tiene el árbol de cajas y los estilos resueltos, pinta. Si el contenido viene del servidor ya renderizado, ese pintado puede ocurrir en 800 milisegundos.
La capacidad de responder depende del JavaScript: los oyentes tienen que estar registrados, el estado tiene que estar construido, los componentes tienen que estar conectados a sus nodos. En una aplicación con hidratación, eso ocurre cuando el bundle ha llegado, se ha parseado, se ha compilado y se ha ejecutado. Si eso son 2.500 milisegundos en un móvil de gama media, hay una ventana de 1.700 milisegundos en la que la página parece terminada y está muerta.
Y no está solo muerta en el sentido de que no haya oyentes: está muerta en el sentido de que el hilo está ocupado ejecutando esa hidratación, así que aunque los oyentes existieran, no se ejecutarían. Los eventos se acumulan en la cola.
Ese es el fenómeno que la gente llama valle inquietante de la hidratación, y es una consecuencia directa del modelo de renderizado en servidor con hidratación en cliente. Las respuestas estructurales son las islas y la hidratación parcial; la respuesta táctica, mientras tanto, es reducir el trabajo de arranque y trocearlo.
Los eventos no se pierden, se acumulan
Un detalle que hay que entender bien porque explica el comportamiento más desconcertante.
Cuando el usuario pulsa durante una tarea larga, el evento no se descarta: se queda en la cola. Cuando el hilo se libera, se procesan todos los eventos acumulados, en orden y de golpe.
La secuencia que ve el usuario:
- Pulsa el botón «Añadir al carrito». No pasa nada.
- Espera medio segundo. Nada.
- Vuelve a pulsar, pensando que el primer clic no se registró.
- El hilo se libera.
- Se procesan los dos clics. Se añaden dos unidades al carrito.
Esto no es un fallo del navegador, es el comportamiento correcto de una cola. Y produce dos consecuencias reales: errores de datos —duplicados en carritos, envíos dobles de formularios— y una degradación adicional, porque procesar dos manejadores en lugar de uno alarga aún más el bloqueo.
La mitigación correcta no es la deduplicación en el cliente, aunque ayude: es dar respuesta visual inmediata, de modo que el usuario sepa que su pulsación se registró. Un botón que se deshabilita en el primer milisegundo del manejador y muestra un estado de espera elimina la segunda pulsación por completo. Es una corrección de percepción que resuelve un problema de datos.
boton.addEventListener('click', async (e) => {
if (boton.disabled) return;
// Respuesta visual antes de cualquier trabajo: esto es lo que ve el usuario
boton.disabled = true;
boton.dataset.estado = 'enviando';
try {
await enviarPedido();
boton.dataset.estado = 'listo';
} catch {
boton.dataset.estado = 'error';
boton.disabled = false;
}
});
Fíjate en que la deshabilitación es lo primero, antes de cualquier await. Si estuviera después, el usuario tendría la misma ventana para el segundo clic.
Cuando el hilo principal está tan ocupado que la página deja de ser usable, los navegadores no se quedan de brazos cruzados. Tienen mecanismos de mitigación, y conocerlos evita diagnósticos equivocados.
El desplazamiento independiente del hilo principal. El desplazamiento no lo ejecuta el hilo principal, sino el hilo del compositor. Por eso una página con el hilo principal completamente bloqueado sigue desplazándose, lo cual es una de las razones de que el usuario crea que la página funciona. La excepción es la que ya vimos: si hay un oyente no pasivo de wheel o de touchmove, el compositor tiene que esperar al hilo principal, y entonces el desplazamiento también se atasca. Esa asimetría es diagnóstica: si la página no responde a clics pero se desplaza bien, es el hilo principal; si tampoco se desplaza, hay un oyente bloqueante.
La compresión de eventos de puntero. Los eventos de movimiento del puntero llegan a mucha más frecuencia de la que el hilo puede procesar. El navegador los agrupa y entrega uno solo por fotograma, con los intermedios accesibles mediante getCoalescedEvents(). Si tu código dibuja trazos y se ven angulosos con el hilo cargado, la causa es esta y la solución es leer los eventos agrupados en lugar del último.
Y el que produce el diagnóstico más confuso: la detección de página no responde. Si el hilo principal se bloquea durante varios segundos, el navegador puede mostrar un diálogo ofreciendo cerrar la página. Ese umbral es de segundos, no de milisegundos, y llegar a él significa una tarea catastróficamente larga: un bucle infinito, un JSON.parse de decenas de megas, una expresión regular con retroceso exponencial.
Ese último caso merece un apunte propio porque es el sospechoso silencioso de muchas tareas larguísimas. Una expresión regular con grupos anidados y cuantificadores puede tener un tiempo de ejecución exponencial en la longitud de la entrada. Con veinte caracteres tarda un microsegundo; con cuarenta, treinta segundos. Y no aparece en el perfil como código tuyo, sino como una única entrada opaca del motor de expresiones regulares.
// Catastrofica: (a+)+ sobre una entrada que no encaja produce retroceso exponencial
const mala = /^(\w+\s?)+$/;
mala.test('a'.repeat(30) + '!'); // puede tardar segundos
// Equivalente lineal
const buena = /^\w+(?:\s\w+)*\s?$/;Si te encuentras una tarea de varios segundos sin funciones tuyas visibles en la pila, mira las expresiones regulares que se aplican a entradas de usuario. Es la causa número uno de ese perfil concreto, y en un formulario de validación en vivo es además un vector de denegación de servicio en el propio cliente.
Qué significa esto para el diseño
Tres consecuencias de diseño que salen directamente de todo lo anterior.
No muestres una interfaz que no funciona. Si la página se pinta antes de ser interactiva, los controles deberían indicar que todavía no están listos. Un botón que parece pulsable y no lo es es peor que uno visiblemente deshabilitado, porque el segundo no genera la expectativa. Es una decisión incómoda —nadie quiere enseñar botones grises— y resuelve el problema de percepción de raíz.
Da respuesta visual antes que resultado. El usuario necesita saber que su acción se registró, y eso puede ocurrir en el primer milisegundo aunque el resultado tarde un segundo. Es el patrón que estructura la optimización del INP.
Deshabilita lo que no se puede repetir. Cualquier acción con efecto secundario en el servidor —pagar, enviar, publicar— tiene que ser inmune a la doble pulsación por construcción, no por suerte.
Estrangula tu CPU a 6x y carga tu aplicación mientras haces clic repetidamente en el botón principal desde el primer momento. Cuenta cuántos clics se acumulan y qué ocurre cuando el hilo se libera. Después mide la ventana exacta entre el primer pintado con contenido y el momento en que un clic produce respuesta visible, con un cronómetro y una grabación de pantalla si hace falta. Esa ventana es el número que hay que enseñar a quien decida las prioridades.