Por qué el botón de agregar al carrito se siente lento y cómo arreglarlo midiendo INP
En la práctica/ 15 min de lectura
El cliente toca agregar al carrito y nada se mueve. Cómo encontrar con INP el script dueño de ese retraso, corregirlo en un tema de Shopify y comprobar que funcionó.
- Shopify
- Rendimiento
- Core Web Vitals
- JavaScript
En este artículo (7)
Un cliente elige una talla, toca Agregar al carrito y no pasa nada. El botón no cambia, no aparece ningún indicador de carga, no se abre el carrito lateral. Así que vuelve a tocar. A veces ese segundo toque agrega una segunda unidad, a veces abre el carrito dos veces, y a veces el cliente ya volvió a los resultados de búsqueda. Quien lo reporta dice "el sitio está lento", y lo primero que revisa el equipo es el servidor.
Aquí el servidor casi nunca es el culpable. Una petición al carrito tarda, pero esperar una respuesta de red no congela la página: el navegador puede pintar, hacer scroll y responder mientras un fetch está en curso. Lo que congela un botón es el hilo principal, el único hilo donde el navegador ejecuta tu JavaScript, atiende la entrada del usuario y produce los cuadros que ves. Si ese hilo está ocupado cuando llega el toque, o se ocupa por culpa del toque, el siguiente cuadro no se puede dibujar y el cliente ve un botón que lo ignoró. Ese retraso tiene una métrica: Interaction to Next Paint, o INP.
Esta guía trata de esa sola interacción. Si te interesa el argumento general de por qué las tiendas se vuelven lentas, lee primero la velocidad de una tienda suele ser un problema de arquitectura. Aquí nos quedamos en la página de producto, en un botón, y en medirlo antes y después de cambiar cualquier cosa.
Qué mide INP
INP observa los clics, los toques y las pulsaciones de teclado durante todo el tiempo que la página está abierta. No cuenta pasar el mouse por encima, hacer zoom ni hacer scroll. Para cada interacción mide el tiempo desde la entrada hasta el siguiente cuadro que el navegador presenta. El INP de la página es la más larga de esas interacciones, descartando un valor atípico por cada 50, así que en la mayoría de las páginas de producto es simplemente la peor interacción de la visita. INP reemplazó a First Input Delay como Core Web Vital en marzo de 2024. FID solo medía la espera antes de que corrieran los manejadores de la primera interacción, así que un botón de agregar al carrito lento podía aprobarlo.
Los umbrales oficiales, medidos en el percentil 75 de las cargas de página en campo y separados entre móvil y escritorio:
- Bueno: 200 milisegundos o menos.
- Necesita mejorar: más de 200 y hasta 500 milisegundos.
- Malo: más de 500 milisegundos.
Cada interacción se divide en tres fases, que la guía de web.dev para optimizar INP define así:
- Retraso de entrada (input delay): va desde el toque hasta que empiezan a correr los callbacks del evento. Es largo cuando el hilo principal ya estaba ocupado con otra cosa, por ejemplo un script que todavía se está inicializando.
- Duración del procesamiento (processing duration): el tiempo que tardan en terminar todos los callbacks de esa interacción. Todos: el manejador del tema y cada manejador que una app haya colgado del mismo botón o formulario.
- Retraso de presentación (presentation delay): el tiempo que necesita el navegador para presentar el siguiente cuadro, es decir, recalcular estilos, layout y pintado después de que los manejadores cambiaron la página.
Desliza la figura para verla completa
La división importa porque cada fase tiene un arreglo distinto. Un retraso de entrada largo es un problema de lo que ya se estaba ejecutando. Una duración de procesamiento larga es un problema de los manejadores. Un retraso de presentación largo es un problema de cuánta página cambiaron esos manejadores.
Por qué pasa en Shopify
El botón de agregar al carrito es el elemento más concurrido de una página de producto. Todo lo que quiere enterarse de una intención de compra lo escucha.
- Apps que se cuelgan del clic. Pop-ups de upsell, bloques de "comprados juntos con frecuencia", barras de envío gratis y carritos laterales de apps suelen añadir su propio listener al botón o al formulario de producto. Algunas interceptan el envío, ejecutan su lógica y luego lo continúan. Cada listener suma a la duración del procesamiento, y el navegador no puede pintar hasta que regresa el último.
- Analítica y etiquetas. Un contenedor de tag manager o un script de marketing pegado a mano en
theme.liquidque envía un evento de add-to-cart desde dentro del manejador del clic, de forma síncrona, armando su propio payload. Los web pixels de Shopify se cargan en un sandbox (estricto para los pixels de apps, laxo para los pixels personalizados) y no dentro del código del tema, lo cual es una razón para mover el tracking ahí. - Scripts del tema. El formulario de producto del tema muchas veces vuelve a renderizar el carrito lateral, el contador, el precio y la barra de envío gratis cuando regresa la petición. Los reemplazos grandes de HTML inflan el retraso de presentación.
- Bloques que se inicializan tarde. Los app blocks y app embeds cargan su propio JavaScript y se configuran al cargar la página. Si el cliente toca mientras un widget de reseñas o un slider todavía está arrancando, el toque espera detrás. Eso es retraso de entrada, y es peor en los primeros segundos después de que aparece la página, que es justo cuando toca un cliente impaciente.
Nada de esto aparece en un log del servidor. La petición al carrito puede volver en una fracción de segundo y la interacción puede seguir siendo mala.
Diagnóstico paso a paso
1. Reprodúcelo en laboratorio con la CPU limitada
Tu laptop es varias veces más rápida que el teléfono que usa la mayoría de tus clientes, así que un toque que se siente instantáneo en tu escritorio puede ser lento en un bolsillo. En Chrome DevTools, abre el panel Performance, abre Capture settings y pon CPU en 4x slowdown. El tutorial del panel Performance de Chrome usa 4x y sugiere 20x para móviles de gama baja. La limitación es relativa a tu equipo, y el panel también puede calibrar presets para tu dispositivo.
Empieza a grabar, elige una variante, toca agregar al carrito, espera al carrito lateral y detén la grabación. Luego lee la traza en este orden, con la referencia del panel Performance a mano:
- En la pista Interactions, busca el clic. Las interacciones de más de 200 milisegundos llevan un triángulo rojo, y al pasar el cursor encima ves por separado el retraso de entrada, el procesamiento y el retraso de presentación.
- En la pista Main, debajo, busca la tarea que empieza en el clic. Las tareas largas llevan un triángulo rojo, y la parte que pasa de 50 milisegundos aparece sombreada en rojo.
- Selecciona esa tarea y abre Bottom-up, agrupado por URL o por dominio. La tabla te dice qué script es dueño del tiempo. Un archivo en el CDN de una app, el tag manager o el script del formulario de producto del tema apuntan cada uno a un dueño distinto y a una conversación distinta.
- Activa Dim 3rd parties para ver de un vistazo cuánto de la tarea es código que no escribiste tú.
Grábalo varias veces, y una vez más durante la carga, tocando en cuanto aparece el botón. Esa segunda grabación es donde aparece el retraso de entrada.
2. Averigua qué interacción falla con clientes reales
El laboratorio te dice qué pasó en tu equipo. Para saber qué elemento y qué fase son lentos para tus clientes, usa la librería web-vitals de Google con su build de atribución, como describe Find slow interactions in the field:
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
const body = JSON.stringify({
inp: value, // ms
rating, // 'good' | 'needs-improvement' | 'poor'
target: attribution.interactionTarget, // selector CSS del elemento tocado
inputDelay: attribution.inputDelay,
processing: attribution.processingDuration,
presentation: attribution.presentationDelay,
loadState: attribution.loadState, // ¿la página seguía cargando?
script: attribution.longestScript?.entry.sourceURL, // solo Chromium
page: location.pathname,
});
// Tu propio colector. sendBeacon entrega aunque la pestaña se esté cerrando.
navigator.sendBeacon('https://collector.example.com/inp', body);
});
interactionTarget es un selector CSS del elemento que tocó el cliente, así que puedes agrupar los reportes y ver si agregar al carrito es de verdad la peor interacción o si lo es el selector de variantes o el botón de cerrar del carrito. Las tres duraciones te dicen en qué fase trabajar. loadState te dice si la página seguía cargando, lo que apunta a retraso de entrada por scripts que todavía arrancan. longestScript viene de la API Long Animation Frames, que solo traen los navegadores basados en Chromium, así que falta para algunos visitantes; cuando está, su sourceURL nombra el script. Si el tema no tiene bundler, el README de la librería muestra cómo cargarla como módulo desde un CDN.
3. Lee los datos de campo, y por qué no coinciden con el laboratorio
PageSpeed Insights muestra dos cosas distintas en una misma página. La sección de arriba son datos de campo del Chrome UX Report (CrUX) del periodo de recolección de los 28 días anteriores, y INP solo aparece ahí. La sección de abajo es una ejecución de laboratorio de Lighthouse que carga la página en un dispositivo simulado y nunca toca tu botón, así que no puede medir el INP de agregar al carrito.
Cuenta con que no van a coincidir. Según la metodología de CrUX, el reporte solo incluye a usuarios de Chrome en escritorio y Android que aceptaron compartir estadísticas de uso; Safari y Chrome en iOS no aportan datos. Una URL necesita suficiente tráfico para tener datos propios, y si no, PageSpeed Insights usa los del origen completo, mezclando tu página de producto con la home. Además, la cifra de campo abarca 28 días, así que un arreglo que publicaste ayer hoy casi no se nota. Para un botón concreto, tus propios datos del paso 2 son la señal de campo más útil.
El arreglo, en orden
El principio sale directo de web.dev: muéstrale algo al cliente en el siguiente cuadro y quita del camino todo lo que no puede ver.
a. Primero, la respuesta visual
Lo primero que hace el manejador es cambiar el botón: un texto como "Agregando…", aria-disabled, quizá una clase. Nada más. Es un cambio mínimo en el DOM y es lo que debe pintar el siguiente cuadro. Si el botón tiene marcado interno, como en los temas que envuelven el texto en un span junto a un indicador de carga, cambia el span y no el botón completo.
b. Cede el hilo principal
Después dale al navegador la oportunidad de pintar ese cambio antes de cualquier trabajo pesado. scheduler.yield() devuelve una promesa y agenda el resto de la función como una continuación priorizada, así que se reanuda antes que otro trabajo en cola de la misma prioridad. MDN lo marca como de disponibilidad limitada, y la guía de tareas largas de web.dev lista Chrome y Edge 129 y Firefox 142, con Safari sin soporte. Por eso detectas la función y, si falta, usas setTimeout, que también cede el hilo, solo que sin la prioridad.
c. La analítica, al final
El tracking no necesita correr antes de que el cliente vea una respuesta. Envíalo después de actualizar el carrito, dentro de requestIdleCallback con un timeout para que corra aunque la página esté ocupada. MDN marca requestIdleCallback como no Baseline, así que también necesita una alternativa.
d. Aplicado a un tema de Shopify
Este es el patrón completo en un formulario de producto que envía a la Ajax Cart API. /cart/add.js recibe un arreglo items con id de variante y cantidad, y Shopify pide URLs que respeten el idioma de la tienda, que es lo que te da window.Shopify.routes.root. Una variante agotada, o una cantidad mayor al stock, regresa un 422 con una description legible.
// Usa scheduler.yield(); si no existe (Safari), cede con setTimeout.
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
// Trabajo no urgente cuando el navegador esté libre, pero nunca después de 2 s.
function whenIdle(fn) {
if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 2000 });
else setTimeout(fn, 0);
}
const form = document.querySelector('form[action$="/cart/add"]');
const button = form.querySelector('[type="submit"]');
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Ignora toques repetidos mientras hay una petición en curso. No es un debounce:
// el primer toque nunca se retrasa.
if (button.getAttribute('aria-disabled') === 'true') return;
// 1. Primero la respuesta visual. Este cambio pequeño es lo que pinta el siguiente cuadro.
const label = button.textContent;
button.setAttribute('aria-disabled', 'true');
button.textContent = 'Agregando…'; // usa la cadena de traducción del tema
// 2. Deja que el navegador presente ese cuadro antes de seguir.
await yieldToMain();
try {
const data = new FormData(form);
const res = await fetch(window.Shopify.routes.root + 'cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: Number(data.get('id')), quantity: Number(data.get('quantity') || 1) }],
}),
});
const json = await res.json();
// 422 significa agotado o stock insuficiente; la description se puede mostrar al cliente.
if (!res.ok) throw new Error(json.description || json.message);
updateCartDrawer(json.items); // la actualización propia del tema: reemplaza solo lo que cambió
// 3. La analítica al final, fuera del camino al siguiente cuadro.
whenIdle(() => trackAddToCart(json.items));
} catch (error) {
showFormError(form, error.message);
} finally {
button.textContent = label;
button.removeAttribute('aria-disabled');
}
});
updateCartDrawer, trackAddToCart y showFormError representan lo que tu tema ya tenga. Dos notas sobre el resto. Primero, este código solo controla tu propio manejador. Si una app escucha el mismo clic y corre durante cientos de milisegundos, sigue corriendo antes del cuadro, y el arreglo para eso es un ajuste en la app, una conversación con el proveedor o quitar la app. La tabla Bottom-up del paso 1 es lo que llevas a esa conversación. Segundo, si el tema vuelve a renderizar el carrito con el parámetro sections, que los endpoints del carrito aceptan para hasta cinco secciones, mantén el reemplazo pequeño. Cambia las líneas y el contador, no el carrito entero, o el costo reaparece como retraso de presentación en la siguiente interacción.
Desliza la figura para verla completa
Qué no hacer
- Ponerle debounce al botón. Un debounce espera antes de ejecutar el manejador, así que suma latencia al primer toque, que es el que importa. Ignorar los toques mientras hay una petición en curso, como hace el código de arriba, evita los agregados dobles sin retrasar nada.
- Quitar la analítica sin avisar. Borrar la etiqueta que envía los eventos de add-to-cart mejora el INP y rompe sin ruido el embudo que usa el equipo de marketing para decidir a dónde va el dinero. Muévela para después o a un pixel, y avisa a quienes leen los reportes antes de que cambien sus números.
- Optimizar sin medir. Reescribir el formulario de producto porque "se siente pesado" puede arreglar un manejador de 20 milisegundos mientras el listener de una app tarda diez veces eso. Mide primero y luego cambia lo que la traza señala.
Lista para revisar una tienda
- INP de campo de las páginas de producto en móvil, desde PageSpeed Insights, anotado con la fecha.
- Tus propios datos de campo con el build de atribución de web-vitals, agrupados por
interactionTarget. - Una traza de DevTools de agregar al carrito con 4x CPU slowdown, una vez después de la carga y otra durante la carga.
- Todos los listeners del botón y del formulario de producto, desde el panel Event Listeners de la pestaña Elements, con el script dueño de cada uno.
- El botón cambia de forma visible en el primer cuadro después del toque.
- Un yield entre ese cambio y cualquier trabajo pesado, con alternativa en
setTimeout. - La analítica después de actualizar el carrito, agendada en tiempo libre, y los dueños de los reportes enterados.
- La actualización del carrito reemplaza solo lo que cambió.
Cómo saber si funcionó
Mide después del cambio exactamente con el mismo método que usaste antes. En laboratorio eso significa el mismo equipo, la misma limitación de CPU, el mismo producto y la misma variante, los mismos pasos y el mismo número de grabaciones; haz cinco y compara la mediana, fase por fase y no solo el total. Un cambio que mueve la duración del procesamiento pero deja igual el retraso de entrada te dice algo distinto de uno que mueve ambos.
En campo, compara tus propios datos durante el mismo tiempo antes y después de publicar, separados por tipo de dispositivo y por interactionTarget. Vuelve a leer PageSpeed Insights solo cuando haya pasado una ventana completa de 28 días desde la publicación, porque hasta entonces la ventana todavía incluye sesiones de antes del arreglo. Y revisa si cambió la mezcla de tráfico mientras tanto: mover presupuesto a social de pago cambia qué teléfonos llegan, y eso mueve el INP por su cuenta.
Un ejemplo ilustrativo, no una medición de ninguna tienda: supón que cinco trazas con la CPU limitada ponen la tarea del clic en unos 400 milisegundos, la mayor parte dentro del listener de una app de upsell, y que después de configurar el upsell para que corra cuando se abre el carrito las mismas cinco trazas muestran menos de 100. Es un resultado de laboratorio que puedes explicarle a cualquiera. Si el percentil 75 de campo baja de 200 milisegundos es una pregunta para los siguientes 28 días, y tus propios datos la van a responder antes.
Si prefieres delegarlo, es parte de lo que hacemos en optimización de rendimiento. El entregable es la traza, la lista de dueños de cada script y un antes y después medido de la misma forma.
Escrito por

Publicado el 21 de octubre de 2026 · actualizado el 23 de octubre de 2026
Para leer después
Hooks de Claude Code: haz que Claude no pueda tocar tu .env
En la práctica / 2026-10-23
Por qué Google Merchant Center rechaza los productos de tu tienda Shopify y cómo corregirlos desde el origen
En la práctica / 2026-10-22
La API de Shopify 2025-10 ya no tiene soporte: cómo pasar a 2026-10 sin romper nada
En la práctica / 2026-10-22