La barra de progreso honesta
Cómo un indicador de progreso cambia la experiencia sin cambiar un milisegundo del tiempo real, qué lo hace creíble, y el código que lo implementa sin producir saltos de layout.
Una barra de progreso no acelera nada. Convierte una espera de duración desconocida en una espera con final visible, y eso basta para que la misma cantidad de segundos se tolere sin frustración. Pero es también el componente que más se implementa mal: con umbrales equivocados produce parpadeos, con geometría equivocada produce saltos de layout, y con una progresión falsa destruye la confianza que la hacía útil.
- Elegir entre indicador indeterminado, progreso determinado y contenido parcial según la duración.
- Implementar el retardo y la duración mínima que evitan el parpadeo.
- Construir un progreso real a partir de las señales disponibles en el navegador.
- Evitar que el indicador contribuya al CLS.
Qué indicador para qué duración
La elección del indicador no es estética, depende de la duración esperada de la operación y de si se conoce.
| Duración esperada | Indicador correcto | Por qué |
|---|---|---|
| Menos de 100 ms | Ninguno | La respuesta llega antes de que nada tenga tiempo de aparecer |
| 100 ms a ~300 ms | Cambio de estado del control pulsado | Basta con que el botón reaccione; un indicador parpadearía |
| ~300 ms a 2 s | Indicador indeterminado o esqueleto | Comunica actividad; la duración es corta y no hace falta cuantificarla |
| 2 s a 10 s | Progreso determinado si se puede | La incertidumbre a partir de aquí empieza a pesar |
| Más de 10 s | Progreso con etapas y opción de cancelar | La tarea ha dejado de ser interactiva |
La primera fila es la que más se incumple. Poner un indicador de carga en una operación que tarda 80 ms produce un destello: aparece y desaparece en menos de cinco fotogramas, y el ojo lo interpreta como un fallo de renderizado, no como información. Es peor que no poner nada.
La segunda fila esconde el requisito más barato y más rentable de toda esta lección: el control pulsado tiene que cambiar de aspecto en el fotograma siguiente al clic, siempre, con independencia de cuánto dure la operación. Si no lo hace, el usuario vuelve a pulsar. El coste es una clase CSS y cero milisegundos de trabajo.
El retardo y la duración mínima
Dos parámetros resuelven casi todos los problemas de un indicador: cuándo aparece y cuánto dura como mínimo.
El retardo de aparición evita el destello en el caso rápido. Si la operación termina antes del retardo, el indicador nunca llega a mostrarse. Un valor entre 200 y 400 ms funciona bien: por debajo empieza a parpadear en conexiones buenas, por encima el usuario ya se ha quedado sin señal durante demasiado tiempo.
La duración mínima evita el destello en el caso límite: si el indicador aparece justo cuando la operación estaba a punto de acabar, se muestra y desaparece de inmediato. Fijar un mínimo de unos 300 ms desde que aparece hasta que puede desaparecer suaviza ese caso. Ojo con la tentación de subirlo: la duración mínima retrasa deliberadamente el resultado, así que es la única técnica de esta lección que empeora el tiempo real. Un mínimo de 300 ms es un peaje aceptable; uno de un segundo es una mentira cara.
// Indicador con retardo de aparicion y duracion minima.
function crearIndicador(el, { retardo = 250, minimo = 300 } = {}) {
let temporizador = null;
let mostradoEn = 0;
return {
empezar() {
temporizador = setTimeout(() => {
mostradoEn = performance.now();
el.hidden = false;
}, retardo);
},
terminar() {
clearTimeout(temporizador);
if (mostradoEn === 0) return; // nunca llego a mostrarse
const visible = performance.now() - mostradoEn;
const espera = Math.max(0, minimo - visible);
setTimeout(() => {
el.hidden = true;
mostradoEn = 0;
}, espera);
},
};
}
performance.now() devuelve un tiempo monótono en milisegundos con resolución de fracción de milisegundo, y no se ve afectado por cambios del reloj del sistema, a diferencia de Date.now(). Para medir duraciones es siempre la opción correcta.
Si el indicador ocupa espacio en el flujo del documento, aparecerá y desplazará el contenido que hay debajo, y luego volverá a desplazarlo al desaparecer. Son dos desplazamientos de layout que suman al CLS y que el usuario percibe como inestabilidad. Colócalo en posición absoluta dentro de un contenedor con el hueco ya reservado, o superpuesto al contenido que sustituye, nunca insertado en el flujo.
Progreso real a partir de señales reales
Un progreso determinado exige saber cuánto falta. En el navegador hay tres fuentes de esa información, en orden de fiabilidad decreciente.
La cabecera Content-Length con un cuerpo leído por trozos. Si el servidor la envía, sabes el total exacto y puedes contar lo recibido. Es el progreso más honesto que existe.
async function descargarConProgreso(url, alProgresar) {
const respuesta = await fetch(url);
const total = Number(respuesta.headers.get('Content-Length')) || 0;
const lector = respuesta.body.getReader();
const trozos = [];
let recibido = 0;
while (true) {
const { done, value } = await lector.read();
if (done) break;
trozos.push(value);
recibido += value.length;
if (total > 0) alProgresar(recibido / total);
}
return new Blob(trozos);
}
Advertencia importante: si la respuesta viene comprimida, Content-Length indica los bytes comprimidos, mientras que value.length cuenta bytes ya descomprimidos. La proporción sigue siendo utilizable como aproximación, pero puede superar el 100%. Acota el valor antes de pintarlo. Y si el servidor usa codificación por trozos sin longitud declarada, Content-Length viene vacío y no hay progreso posible por esta vía.
El recuento de unidades de trabajo. Si la operación son N pasos conocidos, el progreso es trivial y honesto: pasos completados entre pasos totales. Funciona bien para procesos por lotes, subidas de varios archivos o migraciones.
El evento progress de XMLHttpRequest para subidas. Para subir archivos sigue siendo la vía más sencilla de obtener progreso, porque expone loaded y total en el objeto de subida.
function subirConProgreso(url, archivo, alProgresar) {
return new Promise((resolver, rechazar) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) alProgresar(e.loaded / e.total);
});
xhr.addEventListener('load', () => resolver(xhr.response));
xhr.addEventListener('error', () => rechazar(new Error('fallo de red')));
xhr.open('POST', url);
xhr.send(archivo);
});
}
lengthComputable indica si el total es conocido; comprobarlo evita dividir entre cero cuando no lo es.
Cuando ninguna de las tres fuentes está disponible, la respuesta correcta es un indicador indeterminado, no un progreso inventado. Una barra que avanza a velocidad fija hasta el 90% y se queda ahí es la implementación más común y la que más rápido enseña al usuario a no creerse las barras.
El caso especial de la carga inicial
Para la carga de la página en sí, el mejor indicador de progreso no es una barra: es el propio contenido apareciendo. Una página que renderiza el encabezado, luego el texto y luego las imágenes está comunicando progreso de forma continua y con información real, sin componente adicional y sin coste.
Por eso las barras de progreso en la parte superior de la ventana, populares en aplicaciones de una sola página, tienen sentido justamente donde el contenido no puede aparecer progresivamente: en una transición de vista donde la pantalla anterior sigue visible y no hay señal de que algo esté ocurriendo. Ahí la barra sustituye a la señal que el navegador daba de forma nativa en una navegación normal, y que la aplicación ha eliminado al interceptarla.
Durante años interpreté el valor de un indicador como un truco de percepción del tiempo, y solo en parte lo es. El efecto grande está en otro sitio: sin señal, cuando algo tarda, el usuario tiene que decidir a quién atribuir el retraso, y la atribución por defecto es “está roto” o “lo he hecho mal”. Esa atribución dispara acciones destructivas: volver a pulsar, recargar en mitad de una transacción, retroceder. He visto la tasa de duplicados de un formulario de pago caer más de la mitad sin tocar una línea del backend, solo poniendo un estado de espera claro en el botón. El tiempo real no cambió ni un milisegundo. Lo que cambió es que el usuario dejó de creer que su clic no había llegado. Cuando estimes el valor de poner un indicador, no cuentes solo la percepción de la duración: cuenta también los reintentos que evitas, que son carga real de servidor y errores reales de negocio.