Cómo Optimizar INP en 2026: Depuración con LoAF, scheduler.yield() y Código Real

El 43% de los sitios web no pasan INP en 2026. Aprende a diagnosticar y corregir Interaction to Next Paint con la Long Animation Frames API, scheduler.yield() y Web Workers, con código JavaScript listo para producción.

El 43% de los sitios web no pasan el umbral de INP en 2026. Así, sin más. Si tu web se siente lenta al hacer clic, escribir o tocar, lo más probable es que tengas un problema de Interaction to Next Paint. Y lo peor de todo es que no siempre es obvio por qué ocurre.

INP no es como LCP, donde puedes apuntar a una imagen grande y optimizarla. Aquí el enemigo es invisible: tareas de JavaScript bloqueando el hilo principal, event handlers que pesan más de lo que deberían, o un DOM tan enorme que el navegador tarda una eternidad en repintar. Pero hay buenas noticias. Hoy contamos con herramientas modernas como la Long Animation Frames API (LoAF) y scheduler.yield() que hacen el proceso de diagnosticar y corregir INP mucho más metódico.

En esta guía vas a aprender a identificar exactamente qué interacciones fallan, por qué, y cómo arreglarlas con código que puedes copiar y adaptar a tu proyecto. Nada de teoría abstracta — puro código real.

Qué es INP y por qué el 43% de los sitios suspende

Interaction to Next Paint (INP) mide cuánto tarda tu página en responder visualmente a una interacción del usuario: un clic, un toque o una pulsación de tecla. A diferencia de FID, que solo medía la primera interacción, INP evalúa todas las interacciones durante la sesión y reporta la peor (o el percentil 98 si hay más de 50).

Los umbrales son bastante claros:

  • Bueno: ≤ 200 ms
  • Necesita mejora: 200–500 ms
  • Pobre: > 500 ms

¿Por qué tantos sitios fallan? El motivo es estructural. Mejorar INP no se soluciona activando un plugin o añadiendo un atributo HTML mágico. Requiere cambios reales en la arquitectura de tu JavaScript: cómo organizas las tareas, cómo respondes a eventos y cuánto trabajo le pides al navegador entre un clic y el siguiente fotograma pintado.

Las tres fases de una interacción INP

Cada interacción que mide INP se descompone en tres fases. Entender cuál es la que falla en tu caso es imprescindible antes de tocar una sola línea de código:

1. Input Delay (retraso de entrada)

Es el tiempo entre el momento en que el usuario interactúa y el momento en que el navegador puede empezar a ejecutar el event handler. La causa más común es una tarea larga de JavaScript que ya estaba corriendo en el hilo principal. El navegador simplemente no puede interrumpirla, así que tu clic queda esperando en la cola.

2. Processing Time (tiempo de procesamiento)

El tiempo que tardan los event handlers en ejecutarse. Si tu handler de click hace cálculos pesados, manipulación de DOM extensa, o dispara actualizaciones de estado en un framework como React, este tiempo se dispara rápidamente.

3. Presentation Delay (retraso de presentación)

Después de que los handlers terminan, el navegador tiene que recalcular estilos, hacer layout y pintar el nuevo fotograma. Un DOM con miles de nodos o animaciones CSS complejas puede convertir esta fase en la verdadera culpable.

Lo importante aquí (y lo que muchos pasan por alto): INP es la suma de las tres fases. Optimizar solo una puede no ser suficiente si otra está completamente fuera de control.

Paso 1: Diagnosticar INP con la Long Animation Frames API (LoAF)

Honestamente, la Long Animation Frames API es lo mejor que le ha pasado al diagnóstico de rendimiento en mucho tiempo. Disponible desde Chrome 123, va mucho más allá de la antigua Long Tasks API: te dice exactamente qué scripts, funciones y fases están causando frames lentos.

Un frame se considera "largo" cuando supera los 50 ms. LoAF te da la atribución completa: qué script lo causó, desde qué URL, y cuánto tiempo se gastó en cada fase.

Recopilar datos LoAF en producción

// Observar Long Animation Frames en producción
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Solo nos interesan frames con interacciones del usuario
    if (entry.firstUIEventTimestamp > 0) {
      console.log('INP candidate detectado:', {
        duration: entry.duration,
        blockingDuration: entry.blockingDuration,
        firstUIEventTimestamp: entry.firstUIEventTimestamp,
        scripts: entry.scripts.map(s => ({
          sourceURL: s.sourceURL,
          sourceFunctionName: s.sourceFunctionName,
          invokerType: s.invokerType,
          invoker: s.invoker,
          duration: s.duration,
          executionStart: s.executionStart,
          forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration
        }))
      });
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

El campo firstUIEventTimestamp es la clave de todo: si es mayor que cero, significa que ese frame largo fue provocado por una interacción del usuario. Justo lo que impacta INP.

Usar LoAF con la librería web-vitals

La forma más práctica de conectar LoAF con INP es usar el build de atribución de la librería web-vitals de Google. Es lo que yo uso en la mayoría de proyectos y funciona de maravilla:

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

onINP((metric) => {
  const { value, attribution } = metric;
  const { interactionTarget, interactionType } = attribution;

  console.log(`INP: ${value}ms`);
  console.log(`Elemento: ${interactionTarget}`);
  console.log(`Tipo: ${interactionType}`);

  // Desglose de las tres fases
  console.log(`Input Delay: ${attribution.inputDelay}ms`);
  console.log(`Processing Time: ${attribution.processingDuration}ms`);
  console.log(`Presentation Delay: ${attribution.presentationDelay}ms`);

  // Atribución LoAF del script más lento
  if (attribution.longAnimationFrameEntries?.length > 0) {
    const loaf = attribution.longAnimationFrameEntries[0];
    loaf.scripts.forEach(script => {
      console.log(`Script: ${script.sourceURL}`);
      console.log(`Función: ${script.sourceFunctionName}`);
      console.log(`Duración: ${script.duration}ms`);
    });
  }
});

Esto te da un diagnóstico completo: qué interacción falló, en qué fase se perdió el tiempo y qué script fue el responsable. Dejas de adivinar y empiezas a optimizar con precisión quirúrgica.

Paso 2: Depurar INP con Chrome DevTools

Para desarrollo local, Chrome DevTools tiene absolutamente todo lo que necesitas. Este es el flujo de trabajo que mejor me funciona:

  1. Abre DevTools (F12) y ve al panel Performance.
  2. Activa la simulación de CPU: CPU 4x slowdown. Esto es imprescindible — tu máquina de desarrollo es mucho más rápida que el móvil promedio de tus usuarios, y si no simulas esa diferencia, te vas a llevar sorpresas en producción.
  3. Haz clic en Record, interactúa con la página (clics, scroll, escritura en formularios), y para la grabación.
  4. Busca las barras amarillas marcadas como "Long Task" en la línea de tiempo. Cada una que supere los 50 ms es sospechosa.
  5. Haz clic en una tarea larga para ver el desglose en el Call Tree. Ahí vas a encontrar la función exacta que bloquea el hilo principal.

Un truco que me ha ahorrado bastante tiempo: activa el modo Timespan de Lighthouse dentro de DevTools. Te permite grabar un período de interacción y obtener directamente el valor de INP con atribución, sin esperar datos de campo.

Identificar layout thrashing

Uno de los culpables más silenciosos del INP alto es el layout thrashing. Sucede cuando lees una propiedad de layout (como offsetHeight) y luego escribes en el DOM repetidamente dentro de un bucle. Esto fuerza al navegador a recalcular el layout en cada iteración, y el resultado es devastador para el rendimiento.

// ❌ Layout thrashing — causa INP alto
function updateItems(items) {
  items.forEach(item => {
    const height = item.offsetHeight; // Lee layout (fuerza recálculo)
    item.style.height = (height * 2) + 'px'; // Escribe en DOM
  });
}

// ✅ Patrón correcto: leer todo primero, escribir después
function updateItems(items) {
  // Fase de lectura
  const heights = items.map(item => item.offsetHeight);

  // Fase de escritura
  items.forEach((item, i) => {
    item.style.height = (heights[i] * 2) + 'px';
  });
}

En DevTools, el layout thrashing aparece como barras moradas en la línea de tiempo con una etiqueta de advertencia "Forced reflow". Si ves muchas seguidas, ahí está tu problema.

Paso 3: Dividir tareas largas con scheduler.yield()

Aquí es donde empieza la optimización de verdad. scheduler.yield() es la API moderna para ceder el control al navegador desde una tarea larga, y tiene una ventaja crucial sobre todas las alternativas: tu continuación va al frente de la cola de tareas, no al final.

Por qué scheduler.yield() es superior a setTimeout

Cuando usas setTimeout(fn, 0) para ceder el hilo principal, tu trabajo pendiente va al final de la cola. Cualquier otra tarea encolada (scripts de terceros, analíticas, timers aleatorios) se ejecutará antes que tu continuación. Con scheduler.yield(), la continuación se prioriza y retomas tu trabajo rápidamente.

Esta tabla resume las diferencias principales:

CaracterísticasetTimeout(0)requestIdleCallbackscheduler.yield()
Retraso mínimo~4 msSolo en tiempo idleSin retraso artificial
Posición en la colaFinalFinal (cuando idle)Frente
PrioridadNingunaMás bajaHereda la prioridad actual
Throttling en backgroundSevero (1000ms+)SeveroMínimo
Soporte navegadoresUniversalMayoríaChromium (Chrome, Edge)

Patrón de producción con fallback

Como scheduler.yield() todavía no está en Safari ni Firefox (a fecha de abril de 2026), necesitas un fallback. Este es el patrón que deberías usar en producción:

// Polyfill robusto para scheduler.yield
function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

// Uso en una función que procesa datos pesados
async function processLargeDataset(items) {
  const CHUNK_SIZE = 50;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => renderItem(item));

    // Ceder el hilo principal entre chunks
    await yieldToMain();
  }
}

Ejemplo real: optimizar un handler de búsqueda

Un caso que veo repetirse constantemente en auditorías es un campo de búsqueda que filtra una lista grande. Sin optimización, cada pulsación de tecla bloquea el hilo principal y los usuarios lo notan al instante:

// ❌ Bloquea el hilo principal en cada keystroke
searchInput.addEventListener('input', (e) => {
  const query = e.target.value;
  const results = filterProducts(products, query); // Puede tardar 100ms+
  renderResults(results); // Manipulación pesada del DOM
  updateURL(query);
  sendAnalytics('search', query);
});

// ✅ Optimizado con scheduler.yield() y priorización
searchInput.addEventListener('input', async (e) => {
  const query = e.target.value;

  // 1. Trabajo crítico: feedback visual inmediato
  showLoadingIndicator();

  // Ceder para que el navegador pinte el indicador
  await yieldToMain();

  // 2. Trabajo principal: filtrar y renderizar
  const results = filterProducts(products, query);
  renderResults(results);

  // Ceder de nuevo antes del trabajo no crítico
  await yieldToMain();

  // 3. Trabajo secundario: analytics y URL
  updateURL(query);
  requestIdleCallback(() => sendAnalytics('search', query));
});

Fíjate en cómo combinamos tres estrategias diferentes: scheduler.yield() para ceder entre trabajo crítico, separación lógica de prioridades, y requestIdleCallback para lo que puede esperar indefinidamente como analytics. Es la combinación de las tres lo que marca la diferencia.

Paso 4: Mover trabajo pesado a Web Workers

Cuando el procesamiento es inherentemente costoso y no se puede dividir fácilmente en chunks, la mejor opción es sacarlo del hilo principal por completo con un Web Worker. El Worker ejecuta código en un hilo separado, así que no bloquea ninguna interacción del usuario.

// worker.js — se ejecuta en un hilo separado
self.addEventListener('message', (e) => {
  const { products, query } = e.data;

  // Filtrado complejo que en el hilo principal tardaría 200ms+
  const results = products.filter(product => {
    const searchFields = [
      product.name,
      product.description,
      product.category,
      product.tags.join(' ')
    ].join(' ').toLowerCase();

    return query.toLowerCase().split(' ').every(
      term => searchFields.includes(term)
    );
  });

  self.postMessage({ results });
});
// main.js — hilo principal libre para interacciones
const searchWorker = new Worker('worker.js');

searchInput.addEventListener('input', (e) => {
  showLoadingIndicator();
  searchWorker.postMessage({
    products: productCatalog,
    query: e.target.value
  });
});

searchWorker.addEventListener('message', (e) => {
  hideLoadingIndicator();
  renderResults(e.data.results);
});

Con este patrón, el hilo principal solo se encarga de mostrar y ocultar un indicador de carga y renderizar los resultados cuando llegan. Todo el filtrado pesado ocurre en paralelo sin tocar INP. Simple y efectivo.

Paso 5: Optimizar event handlers y el DOM

Debounce y throttle inteligente

Para eventos de alta frecuencia como scroll, resize o input, limitar la tasa de ejecución reduce drásticamente el processing time. Son técnicas básicas, sí, pero te sorprendería cuántos proyectos no las usan donde deberían:

// Debounce: espera a que el usuario deje de escribir
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Throttle: ejecuta como máximo una vez cada X ms
function throttle(fn, limit) {
  let lastRun = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastRun >= limit) {
      lastRun = now;
      fn.apply(this, args);
    }
  };
}

// Aplicación práctica
searchInput.addEventListener('input', debounce(handleSearch, 300));
window.addEventListener('scroll', throttle(handleScroll, 100));

Reducir la complejidad del DOM

Un DOM grande impacta directamente en el Presentation Delay. Después de que los handlers terminan, el navegador recalcula estilos y hace layout sobre todo el árbol. Estas son las recomendaciones concretas que mejor resultado dan en la práctica:

  • Apunta a menos de 1.500 nodos en el DOM visible. Usa document.querySelectorAll('*').length para comprobarlo rápido.
  • Usa content-visibility: auto en secciones fuera del viewport. Esto le dice al navegador que puede saltarse el renderizado de esos elementos hasta que sean visibles:
/* CSS: el navegador omite el rendering de secciones no visibles */
.card-list .card {
  content-visibility: auto;
  contain-intrinsic-size: 0 200px; /* Reserva espacio estimado */
}
  • Virtualiza listas largas. Si muestras cientos de elementos, solo renderiza los visibles. Librerías como @tanstack/virtual hacen esto de forma muy eficiente.
  • Elimina nodos ocultos. Los elementos con display: none siguen existiendo en el DOM y se consideran en los recálculos de estilo. Si realmente no los necesitas, quítalos del DOM directamente en vez de ocultarlos.

Paso 6: Controlar scripts de terceros

Los scripts de terceros son responsables de una cantidad sorprendente de problemas de INP. Analytics, chat en vivo, widgets de redes sociales, pixels de remarketing... cada uno compite por tiempo en el hilo principal, y la mayoría no son precisamente ligeros.

Estrategia de carga diferida por interacción

// Cargar chat widget solo cuando el usuario muestra intención
const chatTrigger = document.querySelector('.chat-button');
let chatLoaded = false;

chatTrigger.addEventListener('click', async () => {
  if (!chatLoaded) {
    const script = document.createElement('script');
    script.src = 'https://chat-widget.example.com/sdk.js';
    script.async = true;
    document.head.appendChild(script);
    chatLoaded = true;

    // Esperar a que cargue antes de inicializar
    await new Promise(resolve => { script.onload = resolve; });
    initChatWidget();
  }
  openChatWindow();
});

Patrón façade para embeds pesados

Para videos de YouTube, mapas o widgets de redes sociales que cargan megabytes de JavaScript, usa una imagen estática como façade y carga el embed real solo al interactuar. Es una técnica que parece sencilla pero el impacto en INP es enorme:

<div class="youtube-facade" data-video-id="dQw4w9WgXcQ">
  <img src="/thumbnails/video-preview.webp"
       alt="Ver vídeo: Optimización de rendimiento web"
       loading="lazy" />
  <button aria-label="Reproducir vídeo">▶</button>
</div>

<script>
document.querySelectorAll('.youtube-facade').forEach(facade => {
  facade.addEventListener('click', function() {
    const videoId = this.dataset.videoId;
    const iframe = document.createElement('iframe');
    iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
    iframe.allow = 'autoplay; encrypted-media';
    iframe.allowFullscreen = true;
    this.replaceWith(iframe);
  }, { once: true });
});
</script>

Este patrón elimina completamente el impacto de los embeds en INP hasta que el usuario decide interactuar con ellos. Cero coste hasta el clic.

Paso 7: Monitorizar INP de forma continua

Optimizar INP no es algo que haces una vez y te olvidas. Cada deploy, cada nuevo script de terceros, cada cambio en un componente puede introducir regresiones. He visto sitios pasar de 150 ms a 400 ms de INP por un simple cambio en un modal. Necesitas monitorización de usuarios reales (RUM) integrada en tu flujo de trabajo.

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

// Enviar datos de INP a tu endpoint de analíticas
onINP((metric) => {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    url: location.href,
    // Información de atribución
    element: metric.attribution.interactionTarget,
    type: metric.attribution.interactionType,
    inputDelay: metric.attribution.inputDelay,
    processingDuration: metric.attribution.processingDuration,
    presentationDelay: metric.attribution.presentationDelay,
    // Timestamp para correlacionar con deploys
    timestamp: Date.now()
  });

  // Usar sendBeacon para no perder datos al cerrar la pestaña
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
});

Mi recomendación: configura alertas cuando el percentil 75 de INP supere los 160 ms (el 80% del umbral de Google). Así detectarás regresiones antes de que afecten tu ventana de CrUX de 28 días y, en consecuencia, tu posicionamiento en buscadores.

Checklist de optimización INP

Antes de dar por terminada la optimización, repasa esta lista. La tengo siempre a mano en cada auditoría:

  1. Diagnosticar: ¿Has identificado las interacciones más lentas con datos de campo (RUM) o LoAF?
  2. Input Delay: ¿Hay tareas largas ejecutándose que bloquean el hilo principal al momento de la interacción? Divídelas con scheduler.yield().
  3. Processing Time: ¿Los event handlers hacen trabajo que podría diferirse? Separa lo visual de lo secundario.
  4. Presentation Delay: ¿El DOM tiene más de 1.500 nodos? ¿Estás usando content-visibility: auto para contenido fuera del viewport?
  5. Terceros: ¿Scripts de analytics, chat o ads están bloqueando interacciones? Difiere su carga hasta que sean necesarios.
  6. Mobile: ¿Has probado con CPU 4x throttling? Los móviles muestran INP entre un 60% y 80% peor que desktop.
  7. Monitorización: ¿Tienes RUM configurado con alertas para detectar regresiones?

Preguntas frecuentes sobre INP

¿INP reemplaza a FID como Core Web Vital?

Sí, desde marzo de 2024, INP sustituyó oficialmente a First Input Delay (FID) como la métrica de interactividad en Core Web Vitals. La diferencia fundamental es que FID solo medía el retraso de la primera interacción, mientras que INP evalúa todas las interacciones durante la sesión completa y reporta la más lenta.

¿Qué interacciones cuenta INP y cuáles no?

INP mide clics del ratón, toques en pantalla táctil y pulsaciones de tecla. No mide interacciones pasivas como scroll o hover. Así que si tu problema de rendimiento es el scroll lento, INP no lo va a capturar, pero sí lo hará si un botón o un campo de texto responde con retraso.

¿Puedo usar scheduler.yield() en todos los navegadores?

scheduler.yield() actualmente solo está disponible en navegadores basados en Chromium (Chrome y Edge). Para Safari y Firefox necesitas un fallback con setTimeout(resolve, 0). El paquete scheduler-polyfill de npm proporciona una emulación bastante fiel del comportamiento de la API completa, incluyendo scheduler.postTask.

¿INP afecta directamente al posicionamiento en Google?

Sí. INP es una señal de ranking dentro de la evaluación de Page Experience de Google. Las páginas con un INP pobre se sienten lentas y poco responsivas, lo que además de afectar la percepción del usuario, puede perjudicar tu posición en los resultados de búsqueda. Hay sitios que al mejorar su INP de 500 ms a menos de 200 ms han reportado mejoras de hasta un 22% en métricas de engagement.

¿Cómo identifico qué script de terceros empeora mi INP?

Usa la librería web-vitals en modo atribución junto con la Long Animation Frames API. El campo scripts de cada entrada LoAF incluye la URL fuente del script responsable. Herramientas como DebugBear y SpeedCurve también ofrecen desgloses por dominio que muestran inmediatamente si el problema viene de tu código o de un tercero.

Sobre el Autor Editorial Team

Our team of expert writers and editors.