§ NOTAS TÉCNICAS aGo · 2026-05-24
Por qué comprimimos con OffscreenCanvas y no WASM
Cuando arrancamos aGo Squeezer teníamos dos caminos claros: portar libsquoosh con sus encoders WASM, o apoyarnos en el encoder nativo del navegador. Elegimos lo segundo y esta nota cuenta el razonamiento, con bundles medidos, código real y los puntos donde la decisión cojea.
El contexto
aGo Squeezer comprime imágenes sin que el archivo salga del navegador. Sin servidor, sin upload, sin cola. Eso fija dos constraints duros: todo el cómputo corre en el cliente, y el bundle inicial define la experiencia. Si la página tarda cuatro segundos en cargar porque metimos un WASM de 1.2 MB, la propuesta de valor se cae sola.
El segundo constraint era operacional. Squeezer es batch típico: el usuario suelta 20 fotos del celular, espera ver un grid con tamaños antes/después y descarga un zip. Latencia importa, pero el cuello de botella real es la memoria del dispositivo móvil cuando se trabaja con varias imágenes grandes en paralelo. Una pestaña con tres workers WASM cargados es un camino corto a out-of-memory en un Android de gama media.
El tercer constraint, y el que terminó pesando más, era de mantenimiento. En aGo lab somos pocos. No podemos darnos el lujo de mantener forks de MozJPEG ni de seguir releases de oxipng cada vez que cambia la API del worker pool. Lo que el navegador ya hace bien es trabajo que no tenemos que hacer nosotros.
Con estos tres pinned al pizarrón evaluamos las opciones reales que había en 2025-2026 para hacer esto en cliente puro.
Las opciones que evaluamos
Tres caminos sobre la mesa. Cada uno con su perfil distinto.
1. libsquoosh con encoders WASM (MozJPEG, OxiPNG, WebP cwebp)
Ventajas reales: control quirúrgico por imagen. Puedes tunear las matrices de cuantización de MozJPEG, ajustar el nivel de optimización de OxiPNG, comparar tres encoders sobre el mismo input. Squoosh.app lo hace y los resultados son notables cuando una persona se sienta a tweakear una sola imagen. Para batch automatizado los encoders WASM también son sólidos.
Contras: cada encoder pesa entre 200 KB y 600 KB en WASM, más el wrapper JS. Si quieres soportar JPEG, PNG, WebP y AVIF cargas cuatro encoders. El bundle se va a 1.5-2 MB cuando le sumas el resto de la página. Carga diferida ayuda, pero la primera imagen siempre paga el costo. Y el debugging, cuando un encoder devuelve un blob roto, es opaco.
2. OffscreenCanvas con canvas.convertToBlob
Ventajas: el encoder ya está en el navegador. Cero bytes adicionales en
el bundle. JPEG, PNG y WebP están en todo browser moderno. AVIF llegó a
Chrome en 2022 y a Safari en 16.4 (marzo 2023). El worker puede ofrecer
la imagen al canvas sin bloquear el main thread, y OffscreenCanvas
se transfiere zero-copy. Debugging es el mismo del browser: DevTools
muestra qué pasa.
Contras: no controlas matrices de cuantización. La calidad la fijas con
un quality de 0 a 1 y ya está. Chrome, Firefox y Safari
producen archivos algo distintos para el mismo quality. Y AVIF no
existe en Safari iOS < 16.4 (en mayo 2026 esto representa una fracción
chica pero no nula de usuarios).
3. sharp-wasm o port de sharp al navegador
Lo evaluamos por completitud, pero sharp está pensado para Node. El port a WASM existe pero es experimental, el bundle es grande y la API obliga a marshalling extra. Lo descartamos rápido para Squeezer.
La decisión y por qué
Elegimos OffscreenCanvas con el encoder nativo y AVIF cuando el navegador lo soporta. La razón corta: el bundle de Squeezer pesa hoy ~120 KB gzip (incluyendo React island, UI y lógica de batch). Una versión prototipo con tres encoders WASM nos había dado ~640 KB gzip. Ahorro de 520 KB sobre el primer load, sin perder soporte de los formatos más usados.
La razón larga tiene tres capas. Primera: el encoder nativo del navegador está mantenido por Google, Mozilla y Apple. Cuando MozJPEG saca una mejora importante (cosa rara, ya está maduro), Chrome la incorpora dentro de unas semanas en su pipeline interno. Nosotros no tenemos que hacer nada. Cuando hay un bug de seguridad en libjpeg-turbo, el navegador parchea. Si hubiéramos embebido MozJPEG en WASM, ese parche se queda fuera hasta que actualicemos.
Segunda capa: medimos calidad percibida en una batería de 200 imágenes (mezcla de fotos, screenshots, logos, ilustraciones). A quality 0.82 el WebP del encoder de Chrome quedó dentro de 3-5 % en peso del WebP de cwebp WASM, y el SSIM medio fue indistinguible en visualización a 1:1. Para usuarios que comprimen imágenes para web o redes (el caso típico de Squeezer), esa diferencia no justifica 600 KB de bundle.
Tercera capa: AVIF. El encoder AVIF de Chrome y Safari produce archivos comparables a libavif. Para fotos, AVIF a quality 0.7 da archivos 25-40 % más chicos que WebP a quality 0.82, con calidad percibida pareja. Que esté ahí gratis es regalo, y simplemente no podemos darnos el lujo de no usarlo.
Cómo lo implementamos
La pieza central es una clase ImageCompressor que vive en
un Web Worker. Recibe el archivo original, decodifica a
ImageBitmap, lo pinta en un OffscreenCanvas y
deja que el navegador encoda al formato target.
// src/islands/squeezer/worker/compressor.ts
// Comprime una imagen con el encoder nativo del navegador.
// Se ejecuta dentro de un Web Worker. No toca el DOM.
type Format = 'image/jpeg' | 'image/webp' | 'image/avif' | 'image/png';
interface CompressOptions {
format: Format;
quality: number; // 0..1, ignorado en PNG
maxWidth?: number; // resize opcional
maxHeight?: number;
}
interface CompressResult {
blob: Blob;
width: number;
height: number;
bytesIn: number;
bytesOut: number;
format: Format;
fellBackTo?: Format;
}
class CompressorError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = 'CompressorError';
}
}
export class ImageCompressor {
private avifSupported: boolean | null = null;
async compress(file: Blob, opts: CompressOptions): Promise<CompressResult> {
const bytesIn = file.size;
const bitmap = await this.decode(file);
const { width, height } = this.fitWithin(bitmap, opts);
// OffscreenCanvas existe en Chrome 69+, Firefox 105+, Safari 16.4+.
// Si falta, caemos a un canvas sintetico via createImageBitmap + canvas DOM
// (no aplica aqui porque corremos en worker; el caller maneja fallback).
if (typeof OffscreenCanvas === 'undefined') {
bitmap.close?.();
throw new CompressorError('OffscreenCanvas no disponible en este worker');
}
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d', { alpha: opts.format !== 'image/jpeg' });
if (!ctx) {
bitmap.close?.();
throw new CompressorError('No se pudo obtener contexto 2D');
}
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close?.();
const targetFormat = await this.resolveFormat(opts.format);
const blob = await canvas.convertToBlob({
type: targetFormat,
quality: targetFormat === 'image/png' ? undefined : opts.quality
});
return {
blob,
width,
height,
bytesIn,
bytesOut: blob.size,
format: targetFormat,
fellBackTo: targetFormat === opts.format ? undefined : targetFormat
};
}
private async decode(file: Blob): Promise<ImageBitmap> {
try {
return await createImageBitmap(file, { imageOrientation: 'from-image' });
} catch (err) {
throw new CompressorError('Decoder del navegador rechazo el archivo', err);
}
}
private fitWithin(b: ImageBitmap, opts: CompressOptions) {
const mw = opts.maxWidth ?? b.width;
const mh = opts.maxHeight ?? b.height;
const ratio = Math.min(mw / b.width, mh / b.height, 1);
return {
width: Math.round(b.width * ratio),
height: Math.round(b.height * ratio)
};
}
// Probe perezoso: solo evaluamos AVIF una vez por sesion del worker.
private async resolveFormat(requested: Format): Promise<Format> {
if (requested !== 'image/avif') return requested;
if (this.avifSupported === null) {
this.avifSupported = await this.probeAvif();
}
return this.avifSupported ? 'image/avif' : 'image/webp';
}
private async probeAvif(): Promise<boolean> {
try {
const probe = new OffscreenCanvas(2, 2);
const ctx = probe.getContext('2d');
if (!ctx) return false;
ctx.fillRect(0, 0, 2, 2);
const blob = await probe.convertToBlob({ type: 'image/avif', quality: 0.5 });
// Algunos navegadores devuelven PNG cuando el tipo pedido no esta soportado.
return blob.type === 'image/avif';
} catch {
return false;
}
}
}
// Wiring del worker. El main thread envia ArrayBuffers, el worker responde
// con el blob comprimido (que es transferible) y metricas.
self.addEventListener('message', async (ev: MessageEvent) => {
const { id, file, opts } = ev.data as {
id: string;
file: Blob;
opts: CompressOptions;
};
const compressor = new ImageCompressor();
try {
const result = await compressor.compress(file, opts);
(self as unknown as Worker).postMessage({ id, ok: true, result });
} catch (err) {
(self as unknown as Worker).postMessage({
id,
ok: false,
error: err instanceof Error ? err.message : String(err)
});
}
});
Tres partes vale la pena mirar. La primera es el probeAvif:
algunos navegadores aceptan image/avif en
convertToBlob pero silenciosamente devuelven PNG si no
pueden encodar. Por eso revisamos blob.type antes de confiar.
Si no es AVIF real, caemos a WebP y reportamos en fellBackTo
para que la UI lo muestre.
La segunda es el fitWithin: limitar el ratio a 1 evita que
ampliemos una imagen chica solo porque el usuario pidió maxWidth grande.
Detalle menor que ahorró bugs cuando Squeezer recibe screenshots de
celular que ya están bajo el target.
La tercera es el cierre explícito del ImageBitmap. En batch
de 30+ imágenes en un Android medio, no cerrarlos lleva a memoria
retenida en GPU y a fallos silenciosos cuatro imágenes después. El
cierre liberó el problema en producción.
Limitaciones que descubrimos
El control de calidad es grueso. quality: 0.82 en Chrome no
produce exactamente el mismo archivo que quality: 0.82 en
Firefox. La diferencia es pequeña pero existe. Si tu producto promete
determinismo bit a bit cross-browser, esto no te sirve y vas a necesitar
WASM.
AVIF no está en Safari iOS bajo 16.4. En mayo 2026, los dispositivos afectados son una fracción menor pero no cero. Resolvimos con fallback automático a WebP. El usuario ve el formato real que descarga, no se promete AVIF si no se entrega AVIF.
El encoder PNG nativo no es tan agresivo como OxiPNG. Para PNGs con paleta pequeña o gráficos planos, OxiPNG saca entre 10 y 30 % más reducción. Para Squeezer asumimos esto: si el usuario está comprimiendo PNGs ya muy optimizados, le sugerimos WebP lossless, que en el encoder nativo gana terreno.
Y el último: OffscreenCanvas en Safari recién llegó estable
en 16.4. Para Safari más viejo mantenemos un fallback con canvas DOM en
el main thread. Funciona, bloquea un poco, y solo aplica a una minoría
de visitas. Lo monitoreamos.
Cuándo NO uses este approach
Si necesitas control quirúrgico por imagen, no es el camino. Squoosh.app existe por una razón y la razón es válida: cuando un diseñador se sienta a optimizar UNA imagen hero y quiere ver tres encoders pelearse pixel por pixel, los encoders WASM brillan. Squeezer no compite ahí ni quiere competir.
Si tu target de soporte incluye browsers desktop legacy (Chrome 60 y
anteriores en kioscos, Edge Legacy, IE11), OffscreenCanvas y
convertToBlob directamente no existen. En ese caso, o
cargas WASM, o asumis polyfills con canvas DOM y bloqueo del main
thread.
Si querés determinismo bit a bit del archivo de salida, por ejemplo para firma digital o hash reproducible cross-browser, los encoders WASM con seed controlada son el camino correcto. El encoder nativo del navegador no garantiza ese determinismo.
Esta nota describe parte de cómo construimos aGo Squeezer. Ábrelo y comprime una carpeta para ver el approach funcionando en vivo.
¿Necesitas algo parecido para tu producto? aGo lab construye software cliente puro para empresas. Conversemos.