Decodificar fuera del hilo principal
Evitar el tirón que produce decodificar una imagen grande justo antes de dibujarla, usando decode, createImageBitmap y un worker de decodificación.
Tres fases, no una
Cuando escribes img.src = url y esperas al evento load, has cubierto solo dos de las tres fases.
Descarga: los bytes llegan por la red. El evento load se dispara cuando termina.
Decodificación: los bytes comprimidos se convierten en una rejilla de píxeles. Puede ocurrir durante la descarga, después, o en el momento del primer dibujo, según el navegador y las circunstancias.
Subida y dibujo: los píxeles llegan a la memoria de vídeo y se componen.
El problema es que la segunda fase no tiene un momento garantizado. En muchos casos el navegador la aplaza hasta que la imagen se necesita de verdad, que es una optimización sensata salvo cuando ese momento coincide con tu bucle de animación.
Una foto de 4000 por 3000 en JPEG tarda del orden de 30 a 80 milisegundos en decodificarse en un portátil, y varias veces más en un móvil de gama media. Eso son entre dos y quince fotogramas perdidos.
decode: adelantar de forma controlada
HTMLImageElement.decode() devuelve una promesa que se resuelve cuando la imagen está descargada y decodificada y lista para dibujar sin bloquear.
const img = new Image();img.src = '/foto-grande.jpg';await img.decode(); // aqui ya no hay sorpresasctx.drawImage(img, 0, 0); // dibujo barato y predecibleComparado con esperar al evento load, la diferencia es exactamente la fase de decodificación. Comparado con no esperar nada, la diferencia es que el tirón ocurre donde tú decides.
Un detalle importante: decode() rechaza si la imagen no se puede decodificar, lo que la convierte también en una forma limpia de detectar errores.
async function cargarLista(url) { const img = new Image(); img.src = url; try { await img.decode(); return img; } catch { throw new Error('Imagen inválida o no disponible: ' + url); }}Y una advertencia: llamar a decode() sobre una imagen cuyo src aún no se ha asignado deja la promesa pendiente indefinidamente en algunos motores. Asigna primero.
createImageBitmap: decodificación completa fuera del hilo
createImageBitmap va un paso más allá: en los navegadores actuales, la decodificación de un Blob ocurre fuera del hilo principal y la promesa se resuelve con la imagen ya lista.
const blob = await (await fetch(url)).blob();const bitmap = await createImageBitmap(blob);ctx.drawImage(bitmap, 0, 0);Frente a decode(), gana en dos cosas: el resultado ya está en un formato apto para la GPU, y puedes reescalar durante la decodificación con resizeWidth, lo que reduce enormemente el coste cuando la imagen se va a mostrar pequeña.
Frente a un elemento <img>, pierde en una: no participa en la carga de recursos del documento, así que no se beneficia de las prioridades de red del navegador ni de srcset. Para la imagen principal de una página, un <img> con decode() suele ser mejor; para recursos de una aplicación gráfica, createImageBitmap es mejor.
Un worker de decodificación
Cuando hay muchas imágenes —una galería, un mapa de teselas, un catálogo— compensa montar un worker dedicado que descargue y decodifique, y devuelva bitmaps transferidos.
// decodificador.js (worker)self.onmessage = async ({ data }) => { const { id, url, opciones } = data; try { const respuesta = await fetch(url); if (!respuesta.ok) throw new Error('HTTP ' + respuesta.status); const blob = await respuesta.blob(); const bitmap = await createImageBitmap(blob, opciones); // Transferir: no se copian los pixeles, se mueve la propiedad self.postMessage({ id, bitmap }, [bitmap]); } catch (e) { self.postMessage({ id, error: String(e) }); }};// hilo principalclass Decodificador { constructor(n = 3) { this.workers = Array.from({ length: n }, () => new Worker('/decodificador.js')); this.pendientes = new Map(); this.siguiente = 0; this.id = 0; for (const w of this.workers) { w.onmessage = ({ data }) => { const pendiente = this.pendientes.get(data.id); if (!pendiente) return; this.pendientes.delete(data.id); data.error ? pendiente.rechazar(new Error(data.error)) : pendiente.resolver(data.bitmap); }; } } cargar(url, opciones) { const id = ++this.id; const w = this.workers[this.siguiente++ % this.workers.length]; return new Promise((resolver, rechazar) => { this.pendientes.set(id, { resolver, rechazar }); w.postMessage({ id, url, opciones }); }); } terminar() { for (const w of this.workers) w.terminate(); }}Con tres workers, la decodificación de una galería se paraleliza y el hilo principal solo recibe bitmaps listos. El coste en el hilo principal por imagen se reduce a la recepción del mensaje, que con transferencia es prácticamente cero porque no se copia nada.
Medir antes de optimizar
Todo lo anterior tiene coste de complejidad, así que conviene medir si el problema existe. La medición directa:
async function medirDecodificacion(url) { const blob = await (await fetch(url)).blob(); const t0 = performance.now(); const bitmap = await createImageBitmap(blob); const t1 = performance.now(); const c = document.createElement('canvas'); c.width = c.height = 1; c.getContext('2d').drawImage(bitmap, 0, 0, 1, 1); const t2 = performance.now(); bitmap.close(); return { decodificar: +(t1 - t0).toFixed(2), subir: +(t2 - t1).toFixed(2), dimensiones: `${bitmap.width}×${bitmap.height}`, };}Si la suma de ambos tiempos está por debajo de unos pocos milisegundos, no hay problema que resolver. Si supera los diez, ya vale la pena adelantar la decodificación; si supera los treinta, hace falta el worker o reducir la resolución de origen, que casi siempre es la respuesta correcta y la que nadie propone.