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

Tres casos típicos: el filtro de una lista, el acordeón y el envío de formulario

Los tres patrones de interacción que aparecen en casi todas las aplicaciones, dónde se va el tiempo en cada uno, y la implementación completa optimizada de los tres.

⏱ 20 min

Tres interacciones concentran la mayoría de los problemas de INP del mundo real, y las tres tienen una versión ingenua que funciona con datos de prueba y se derrumba con datos reales. Verlas resueltas de principio a fin, con el reparto entre las tres partes de la métrica en cada caso, es la mejor forma de fijar todo lo anterior del nivel.

🎯 Al terminar esta lección sabrás
  • Optimizar un filtro de lista con la combinación de cancelación, cesión y virtualización.
  • Resolver el coste de un acordeón que expande contenido pesado.
  • Hacer que un envío de formulario responda de inmediato sin arriesgar duplicados.
  • Reconocer en cada caso qué parte del INP domina y por qué.

Caso 1: el filtro de una lista

El problema. El usuario escribe en un campo de búsqueda. Cada pulsación filtra un array de miles de elementos y vuelve a renderizar la lista. Con 200 elementos va bien; con 5.000 cada tecla cuesta 400 milisegundos, y como el usuario escribe rápido, las tareas se encolan y la interfaz se congela.

Dónde se va el tiempo. Reparto típico: procesamiento alto por el filtrado, presentación alta por el renderizado de la lista, entrada moderada porque las pulsaciones anteriores todavía se están procesando.

La solución completa:

import { ceder } from './planificacion.js';

const campo = document.querySelector('#buscar');
const lista = document.querySelector('#resultados');
const contador = document.querySelector('#contador');

let generacion = 0;

campo.addEventListener('input', async (e) => {
  const consulta = e.target.value.trim().toLowerCase();
  const mia = ++generacion;

  // 1. RESPUESTA VISUAL inmediata: el usuario ve que algo pasa
  lista.dataset.estado = 'buscando';

  // 2. Ceder: el fotograma con el estado 'buscando' se pinta ya
  await ceder();
  if (mia !== generacion) return;          // el usuario siguio escribiendo

  // 3. Filtrar por lotes, cediendo entre ellos
  const coincidencias = [];
  const LOTE = 2000;
  for (let i = 0; i < productos.length; i += LOTE) {
    if (mia !== generacion) return;
    for (let j = i; j < Math.min(i + LOTE, productos.length); j++) {
      if (productos[j].busqueda.includes(consulta)) coincidencias.push(productos[j]);
    }
    await ceder();
  }
  if (mia !== generacion) return;

  // 4. Renderizar solo lo visible; el resto llega al desplazarse
  renderizarVentana(lista, coincidencias, 0, 50);
  contador.textContent = `${coincidencias.length} resultados`;
  delete lista.dataset.estado;
});

Cuatro decisiones que hacen la diferencia.

El testigo de generación en lugar de limitar la frecuencia. La versión clásica espera 300 milisegundos tras la última pulsación antes de filtrar. Eso reduce el trabajo y añade 300 milisegundos de latencia percibida a todo el mundo. Con generación y cancelación, el filtrado empieza de inmediato y se abandona en cuanto llega otra tecla: menos latencia y menos trabajo a la vez.

El campo busqueda precalculado. Normalizar y pasar a minúsculas cada elemento en cada pulsación es trabajo repetido miles de veces. Precalcularlo una vez al cargar los datos elimina esa multiplicación.

El troceado del filtrado. Con 5.000 elementos y lotes de 2.000, son tres trozos con dos cesiones. Suficiente para no bloquear.

Renderizar una ventana, no todo. Cinco mil resultados no se pueden pintar. Renderizar los cincuenta primeros y ampliar al desplazarse convierte el retraso de presentación en constante, independiente del número de coincidencias.

Caso 2: el acordeón

El problema. Un panel plegable que al abrirse muestra contenido pesado: una tabla, un mapa, un gráfico. El clic tarda 500 milisegundos en producir cualquier cambio visual porque todo el contenido se construye antes de que la sección se expanda.

Dónde se va el tiempo. Presentación dominante, con procesamiento moderado. El manejador es corto; lo caro es renderizar lo que aparece.

La solución:

const acordeon = document.querySelector('#acordeon');

acordeon.addEventListener('click', async (e) => {
  const cabecera = e.target.closest('[data-acordeon-cabecera]');
  if (!cabecera) return;

  const panel = document.getElementById(cabecera.getAttribute('aria-controls'));
  const abierto = cabecera.getAttribute('aria-expanded') === 'true';

  // 1. RESPUESTA VISUAL: el estado del control cambia ya, sin tocar el contenido
  cabecera.setAttribute('aria-expanded', String(!abierto));
  panel.hidden = abierto;

  if (abierto) return;                       // cerrar no cuesta nada

  // 2. Si el contenido ya se construyo, no hay mas que hacer
  if (panel.dataset.cargado === 'si') return;

  panel.dataset.estado = 'cargando';
  await ceder();                             // el panel vacio ya esta pintado

  // 3. Ahora, fuera de la interaccion medida, construir el contenido
  const { montar } = await import(`./paneles/${panel.dataset.modulo}.js`);
  await montar(panel);
  panel.dataset.cargado = 'si';
  delete panel.dataset.estado;
});

Y el CSS que hace que la expansión no cueste layout de toda la página:

[data-acordeon-panel] {
  contain: layout style;
  content-visibility: auto;
  contain-intrinsic-size: auto 400px;
}
[data-acordeon-panel][data-estado="cargando"] {
  min-block-size: 400px;                     /* reserva el espacio: cero desplazamiento */
}

La pieza clave es que el panel se expande vacío y con altura reservada en el primer fotograma, y el contenido llega después. El usuario ve una respuesta inmediata, el layout no salta cuando el contenido llega, y el módulo del panel ni siquiera estaba en el bundle inicial.

El min-block-size durante la carga y el contain-intrinsic-size cumplen la misma función desde dos ángulos: garantizar que la caja tiene su tamaño antes de tener contenido.

Caso 3: el envío de formulario

El problema. El usuario pulsa enviar. Se valida el formulario, se serializa, se envía, y hasta que la respuesta llega no ocurre nada visible. En una red lenta son dos segundos de aparente inacción, y el usuario vuelve a pulsar.

Dónde se va el tiempo. Depende de la validación. Si la validación es síncrona y compleja —expresiones regulares sobre muchos campos, comprobaciones cruzadas— el procesamiento domina. Si no, el problema no es de INP sino de percepción, y aun así se resuelve igual.

La solución:

const formulario = document.querySelector('#alta');
const enviar = formulario.querySelector('button[type="submit"]');
let enVuelo = null;

formulario.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (enVuelo) return;                       // proteccion contra doble envio

  // 1. RESPUESTA VISUAL sincrona y minima
  enviar.disabled = true;
  enviar.dataset.estado = 'enviando';
  formulario.setAttribute('aria-busy', 'true');

  // 2. Ceder: el boton deshabilitado ya esta en pantalla
  await ceder();

  // 3. Validar fuera de la interaccion medida
  const errores = validar(formulario);
  if (errores.length) {
    mostrarErrores(errores);
    errores[0].campo.focus();                // el foco al primer error
    enviar.disabled = false;
    delete enviar.dataset.estado;
    formulario.removeAttribute('aria-busy');
    return;
  }

  // 4. Enviar, con cancelacion y sin dejar el boton colgado pase lo que pase
  const control = new AbortController();
  enVuelo = control;
  try {
    const r = await fetch('/api/alta', {
      method: 'POST',
      body: new FormData(formulario),
      signal: control.signal,
      headers: { 'Idempotency-Key': claveIdempotencia(formulario) },
    });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    mostrarExito(await r.json());
  } catch (err) {
    if (err.name !== 'AbortError') mostrarError(err);
    enviar.disabled = false;
  } finally {
    enVuelo = null;
    delete enviar.dataset.estado;
    formulario.removeAttribute('aria-busy');
  }
});

Tres detalles que separan esta versión de la ingenua.

La deshabilitación es lo primero y es síncrona. No después de validar, no después del await. Es lo que elimina el doble envío en el cliente.

La clave de idempotencia. La protección del cliente no basta: una red intermitente puede provocar un reintento del navegador o del usuario en otra pestaña. Una cabecera de idempotencia permite al servidor reconocer el envío repetido y devolver el mismo resultado en lugar de crear dos registros. Es la única protección real.

El aria-busy y el foco al primer error. Sin ellos, un usuario de lector de pantalla no se entera ni de que el formulario está enviando ni de que hay errores. Son dos líneas y cambian la usabilidad por completo para una parte de tus usuarios.

Los tres casos comparten la misma estructura, y esa estructura es un patrón que conviene extraer

Si comparas los tres manejadores, tienen exactamente la misma forma:

  1. Cambio visual mínimo, síncrono.
  2. Cesión.
  3. Comprobación de vigencia.
  4. Trabajo esencial, troceado si es largo.
  5. Trabajo diferible, con prioridad baja.

Esa repetición es una señal de que hay una abstracción, y extraerla evita que cada persona del equipo implemente el patrón a medias. Esta es la versión que uso:

import { ceder } from './planificacion.js';

let generaciones = new Map();

/**
 * Ejecuta una interaccion en dos fases: respuesta visual inmediata
 * y trabajo posterior cancelable por una nueva invocacion de la misma clave.
 */
export async function interaccion(clave, { visual, trabajo, diferido }) {
  const gen = (generaciones.get(clave) ?? 0) + 1;
  generaciones.set(clave, gen);

  visual();                                    // sincrono, minimo
  await ceder();                               // el navegador pinta

  const vigente = () => generaciones.get(clave) === gen;
  if (!vigente()) return;

  await trabajo(vigente);                      // recibe el comprobador

  if (diferido && vigente()) {
    programar(diferido, { priority: 'background' });
  }
}

Y el uso, que hace explícita la estructura en cada punto de la aplicación:

campo.addEventListener('input', (e) => {
  const consulta = e.target.value;
  interaccion('busqueda', {
    visual: () => { lista.dataset.estado = 'buscando'; },
    trabajo: async (vigente) => {
      const res = await filtrarPorLotes(productos, consulta, vigente);
      if (!vigente()) return;
      renderizarVentana(lista, res, 0, 50);
      delete lista.dataset.estado;
    },
    diferido: () => analitica.evento('busqueda', { consulta }),
  });
});

Tres beneficios de tenerlo como abstracción y no como patrón que cada uno recuerda.

Uno: la comprobación de vigencia deja de olvidarse. Es la parte que más se omite y la que produce los fallos más raros.

Dos: el trabajo diferido siempre acaba en la prioridad correcta. Sin la abstracción, la mitad de las veces se queda en el manejador porque «total, son 8 milisegundos», y esos ochos se acumulan.

Tres, y es el que más vale a largo plazo: hace la estructura visible en la revisión de código. Un manejador que no usa interaccion destaca, y quien revisa puede preguntar por qué. Sin la abstracción, un manejador de sesenta líneas mezclando las tres categorías pasa desapercibido porque es lo normal.

El único cuidado que exige: la clave tiene que ser estable y específica. Usar la misma clave para dos interacciones distintas hace que se cancelen entre ellas, que es un fallo sutil y difícil de encontrar. Una convención que funciona es usar el identificador del elemento o una constante exportada junto al componente.

⚔️ Reto práctico

Implementa la abstracción interaccion y migra los tres manejadores más lentos de tu aplicación. Para cada uno, mide antes y después las tres partes del INP por separado con la instrumentación de campo, esperando una semana de datos en cada versión. Comprueba especialmente que el retraso de entrada de la interacción siguiente no ha empeorado: si lo ha hecho, el trabajo diferido necesita troceado además de prioridad baja.