wandres.dev
OPTIMIZAR INP · La interacción que responde

Reducir el retraso de entrada: liberar el hilo antes de que el usuario actúe

Las cuatro fuentes de ocupación del hilo que retrasan el inicio de un manejador, cómo identificar cuál domina en tu caso, y las correcciones concretas para cada una, incluida la negociación con los scripts de terceros.

⏱ 18 min

El retraso de entrada es la parte del INP que no tiene nada que ver con tu manejador: es lo que el usuario espera antes de que empiece a ejecutarse. Es puro tiempo perdido, sin ningún trabajo útil asociado a la interacción, y por eso es la parte más frustrante de las tres y también la más satisfactoria de arreglar, porque cada milisegundo que quitas se convierte directamente en respuesta más rápida.

🎯 Al terminar esta lección sabrás
  • Enumerar las cuatro fuentes de ocupación del hilo y sus firmas en un perfil.
  • Determinar cuál domina con la instrumentación de campo y con el perfilador.
  • Aplicar la corrección específica de cada fuente.
  • Controlar el coste de los scripts de terceros sobre el retraso de entrada.

Las cuatro fuentes

Fuente uno: el arranque. Evaluación del bundle, hidratación, montaje inicial. Es la fuente dominante en los primeros segundos de vida de la página y desaparece después. Su firma es un retraso de entrada altísimo en las interacciones tempranas y bajo en las tardías.

Fuente dos: el trabajo periódico. Temporizadores de sondeo, actualizaciones automáticas, animaciones que corren en el hilo principal, observadores que se disparan con frecuencia. Su firma es un retraso de entrada moderado y constante a lo largo de toda la sesión.

Fuente tres: los scripts de terceros. Analítica, publicidad, mapas de calor, pruebas A/B, chat. Su firma es un retraso de entrada que aparece en ráfagas, correlacionado con la carga de esos scripts, y que no se reproduce en desarrollo.

Fuente cuatro: el trabajo diferido mal planificado. Precargas, indexados, procesamiento en segundo plano que no cede. Su firma es un retraso de entrada alto en momentos concretos y predecibles.

Distinguirlas exige el dato temporal, y por eso conviene enviar el instante de la interacción junto con la métrica:

import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution: a }) => {
  navigator.sendBeacon('/telemetria/inp', JSON.stringify({
    valor: Math.round(value),
    entradaMs: Math.round(a.inputDelay),
    // Segundos transcurridos desde el inicio de la navegacion
    desdeCarga: Math.round(a.interactionTime / 1000),
    objetivo: a.interactionTarget,
  }));
});

Con ese campo, un gráfico de retraso de entrada frente a segundos desde la carga separa la fuente uno de las demás en un vistazo: si la nube se concentra por debajo de los cinco segundos, es arranque.

Corregir el arranque

Todo el trabajo de los niveles anteriores converge aquí. Menos JavaScript inicial, troceado por ruta, diferir lo que no se ve. No hay nada nuevo que añadir salvo dos técnicas específicas del momento de la hidratación.

Hidratar por prioridad, no por orden del árbol. Los componentes con los que el usuario puede interactuar de inmediato —la navegación, el buscador, el botón principal— antes que los que están fuera de la pantalla. Con islas esto es la estrategia de carga por isla; sin islas, es programar la hidratación de los componentes secundarios con prioridad baja.

Trocear la hidratación con cesión. Si tu marco lo permite, hidratar en trozos con cesión del hilo convierte una tarea de 900 milisegundos en veinte de 45, y el retraso de entrada máximo pasa de 900 a 45. El trabajo total sube ligeramente y la experiencia cambia por completo.

Y una técnica de percepción que no reduce el retraso pero elimina el problema real que causa: registrar un manejador mínimo antes de la hidratación completa que capture la interacción y la reproduzca cuando el componente esté listo.

// Antes de hidratar: capturar y guardar
const pendientes = [];
document.addEventListener('click', (e) => {
  if (window.__hidratado) return;
  const boton = e.target.closest('[data-accion]');
  if (!boton) return;
  e.preventDefault();
  pendientes.push({ accion: boton.dataset.accion, elemento: boton });
  boton.dataset.estado = 'pendiente';        // respuesta visual inmediata
}, { capture: true });

// Al terminar de hidratar: reproducir
export function alTerminarHidratacion() {
  window.__hidratado = true;
  for (const p of pendientes) {
    delete p.elemento.dataset.estado;
    p.elemento.click();
  }
  pendientes.length = 0;
}

Esto no baja el retraso de entrada medido —el manejador real sigue empezando tarde— y sí resuelve el problema que el usuario tenía: su clic no se pierde y recibe confirmación visual inmediata.

Corregir el trabajo periódico

La búsqueda es mecánica y suele producir hallazgos:

grep -rn "setInterval\|requestAnimationFrame" src/ | grep -v test

Para cada resultado, tres preguntas: ¿se cancela cuando el componente se desmonta? ¿se para cuando la pestaña está oculta? ¿la frecuencia es la mínima necesaria?

La tercera es la que más rinde. Un sondeo cada segundo para un dato que cambia cada minuto es cincuenta y nueve ejecuciones desperdiciadas, cada una con su posibilidad de coincidir con un clic del usuario.

// Sondeo que se pausa cuando nadie mira y que no se acumula
let temporizador = null;

function arrancarSondeo(intervaloMs = 30_000) {
  pararSondeo();
  temporizador = setInterval(async () => {
    if (document.visibilityState !== 'visible') return;
    await actualizarDatos();
  }, intervaloMs);
}

function pararSondeo() {
  if (temporizador) clearInterval(temporizador);
  temporizador = null;
}

document.addEventListener('visibilitychange', () => {
  document.visibilityState === 'visible' ? arrancarSondeo() : pararSondeo();
});

Y para animaciones: cualquier animación que se pueda expresar en CSS con transform y opacity no debería estar en JavaScript, porque el compositor la ejecuta fuera del hilo principal y deja de contribuir al retraso de entrada por completo. Es la migración con mejor relación entre esfuerzo y resultado de esta lección.

Corregir los terceros

Los scripts de terceros son la fuente que más retraso de entrada aporta en sitios de contenido y comercio, y la que menos control tienes. Cuatro palancas, de más a menos eficaz.

Uno: quitarlos. Suena trivial y es la que más funciona. Un inventario de scripts de terceros con su coste medido y su valor de negocio produce, en casi todas las auditorías, dos o tres que nadie usa y que están porque alguien los puso hace tres años. La medición del coste individual es directa:

const porOrigen = new Map();
for (const r of performance.getEntriesByType('resource')) {
  if (r.initiatorType !== 'script') continue;
  const origen = new URL(r.name).origin;
  if (origen === location.origin) continue;
  const actual = porOrigen.get(origen) ?? { kb: 0, n: 0 };
  actual.kb += r.transferSize / 1024;
  actual.n++;
  porOrigen.set(origen, actual);
}
console.table(Object.fromEntries(
  [...porOrigen].sort((a, b) => b[1].kb - a[1].kb).map(([k, v]) => [k, { KB: Math.round(v.kb), peticiones: v.n }])
));

Ese cuadro, con el coste en hilo principal de cada origen sacado de la API de fotogramas largos, es la herramienta de negociación. «El mapa de calor cuesta 180 KB y 340 milisegundos de bloqueo por sesión» es un argumento; «los terceros van lentos» no lo es.

Dos: cargarlos tarde y con prioridad baja. Nada de terceros en el head con async. Un <script defer> al final, o mejor, cargarlos tras el evento de carga y en tiempo ocioso.

Tres: cargarlos por interacción. Un chat de soporte no necesita estar cargado hasta que alguien pulsa el botón de chat. La técnica de fachada resuelve esto.

Cuatro: aislarlos. Un <iframe> con el tercero dentro mueve su trabajo a otro documento, con su propio hilo en algunos casos, y el impacto sobre tu hilo principal baja mucho. No siempre es posible porque muchos scripts necesitan acceso al DOM principal.

El retraso de entrada que ves en campo incluye tiempo que no está en ningún perfil tuyo

Hay una parte del retraso de entrada que no aparece en el perfilador y que en dispositivos reales puede ser la mitad del total: el tiempo que el evento pasa antes de llegar a tu documento.

El recorrido completo de una pulsación incluye el sistema operativo, el proceso del navegador, la decisión de a qué documento va, y el paso al proceso de renderizado. Cada salto tiene coste, y en un dispositivo con la CPU saturada esos saltos se alargan porque los procesos compiten por planificación. En un móvil de gama baja con varias aplicaciones abiertas, el evento puede tardar entre 20 y 60 milisegundos en llegar al hilo principal aunque el hilo esté completamente libre.

Eso explica una observación desconcertante que aparece al comparar laboratorio con campo: un percentil 75 de retraso de entrada de 40 o 50 milisegundos con un hilo principal aparentemente ocioso. No es un error de medición y no hay nada en tu código que lo cause.

Tres consecuencias prácticas.

Uno: existe un suelo que no puedes bajar. Perseguir un retraso de entrada de cero es perseguir algo imposible. El objetivo razonable en móvil está en torno a 30 o 50 milisegundos en el percentil 75, y por debajo de eso el trabajo está en las otras dos partes.

Dos: reducir el trabajo del hilo mejora también esta parte, indirectamente. Un dispositivo con la CPU al máximo por tu JavaScript planifica peor todos sus procesos, incluido el del navegador. Bajar la carga de trabajo reduce el suelo, aunque no lo elimine.

Tres: y la que más se ignora, la limitación térmica multiplica esto. Un teléfono caliente baja la frecuencia de todos sus núcleos, y el retraso de entrada, que depende de la planificación del sistema, se degrada antes que ninguna otra cosa. Si tu producto se usa en sesiones largas —un juego, un editor, una aplicación de trabajo— el retraso de entrada del minuto quince es sistemáticamente peor que el del minuto uno, y ningún perfil de laboratorio de treinta segundos lo captura.

La forma de comprobar si estás contra el suelo o contra tu propio código: segmenta el retraso de entrada por segundos transcurridos desde la carga y mira el valor asintótico. Si baja hasta estabilizarse en 40 y no baja más, eso es el suelo del dispositivo y el trabajo está en otra parte. Si se queda en 200, sigue habiendo algo tuyo ocupando el hilo.

⚔️ Reto práctico

Segmenta tu retraso de entrada de campo por segundos desde la carga y dibuja la curva. Identifica si domina la fuente de arranque, la periódica o la de terceros según la forma. Después ejecuta el inventario de scripts de terceros con su coste en kilobytes y en bloqueo, y lleva la tabla a quien decida sobre ellos con una propuesta concreta de qué quitar, qué diferir y qué convertir en fachada.