pipeline serverless de imágenes con aws lambda+node+wasm: de 11.5mb a 91.2kb
Hace un tiempo me encontré con la necesidad de procesar miles de imágenes para un e-commerce: varias versiones por producto, carrusel, thumbnail, miniatura para facturas. El proceso manual no era opción. Los scripts en bash que ejecutaba desde mi máquina funcionaban, pero dependían de mí para correr. La solución era clara: un pipeline en la nube que se disparara solo cada vez que llegara una imagen nueva.
Este post es sobre cómo construí ese pipeline en AWS Lambda usando beautiful-image, y por qué WASM fue la decisión clave que hizo que todo encajara sin fricción.
La arquitectura
El flujo es simple y completamente serverless:
Sin servidores que mantener, sin workers que escalar, sin colas que configurar para el caso básico. Una imagen entra, dos variantes salen, listas para servir desde CloudFront.
Por qué WASM cambia todo
beautiful-image tiene un core en Rust compilado a WASM. Eso significa:
- Un solo artefacto: el binario WASM de 469KB va dentro del mismo ZIP de la Lambda. Sin Layers, sin Docker images.
- Portabilidad real: el mismo paquete corre en
nodejs22.xcon arquitecturax86_64oarm64sin recompilar nada. - Zero dependencias nativas:
npm installes suficiente. No hay nada que compilar.
El deploy queda en tres comandos:
npm install
sam build
sam deploy
La implementación
El handler es directo. Descarga la imagen de S3, genera las variantes con beautiful-image y las sube al bucket de destino:
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { image } from "beautiful-image/node"
import path from "node:path"
const SOURCE_BUCKET = process.env.SOURCE_BUCKET!
const DEST_BUCKET = process.env.DEST_BUCKET!
const s3 = new S3Client({})
const VARIANTS = [
{ folder: "optimized", width: 800, quality: 80 },
{ folder: "thumbnails", width: 200, quality: 80 },
] as const
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"])
export const handler = async (event: AWSLambda.S3Event): Promise<void> => {
for (const record of event.Records) {
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "))
const ext = path.extname(key).toLowerCase()
if (!ALLOWED_EXTENSIONS.has(ext)) {
console.log(`Skipping unsupported file type: ${key}`)
continue
}
const t0 = performance.now()
console.log(`Processing: ${key}`)
let input: Buffer
try {
const t1 = performance.now()
const { Body } = await s3.send(
new GetObjectCommand({ Bucket: SOURCE_BUCKET, Key: key })
)
if (!Body) {
console.error(`Empty body for key: ${key}`)
continue
}
input = Buffer.from(await Body.transformToByteArray())
console.log(`[timer] download: ${(performance.now() - t1).toFixed(0)}ms ${input.length}B`)
} catch (err) {
console.error(`Failed to download ${key}:`, err)
continue
}
const filename = path.parse(key).name
for (const { folder, width, quality } of VARIANTS) {
const tp = performance.now()
let result: Awaited<ReturnType<ReturnType<typeof image>["toJpeg"]>>
try {
result = await image(input).resize(width).toJpeg(quality)
} catch (err) {
console.error(`Failed to process variant ${folder} for ${key}:`, err)
continue
}
console.log(`[timer] wasm ${folder} (${width}px): ${(performance.now() - tp).toFixed(0)}ms`)
const destKey = `${folder}/${filename}.jpg`
const tu = performance.now()
try {
await s3.send(
new PutObjectCommand({
Bucket: DEST_BUCKET,
Key: destKey,
Body: result.data,
ContentType: "image/jpeg",
})
)
} catch (err) {
console.error(`Failed to upload ${destKey}:`, err)
continue
}
console.log(`[timer] upload ${destKey}: ${(performance.now() - tu).toFixed(0)}ms`)
console.log(
`Saved ${destKey} - ${result.originalSize}B → ${result.optimizedSize}B (${Math.round(result.compressionRatio * 100)}% smaller)`
)
}
console.log(`[timer] total: ${(performance.now() - t0).toFixed(0)}ms`)
}
}
La API de beautiful-image es encadenable y lee como lo que hace: descarga, redimensiona, comprime, etc.
const result = await image(input).resize(800).toJpeg(80)
// result.data Buffer listo para subir
// result.originalSize tamaño original en bytes
// result.optimizedSize nuevo tamaño en bytes
// result.compressionRatio 0.99 = 99% más pequeño
El template SAM
Todo el stack en ~64 líneas de YAML. La Lambda se dispara con cualquier s3:ObjectCreated en el bucket de origen:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Beautiful Image processor Lambda triggered by S3 uploads using beautiful-image
Parameters:
SourceBucketName:
Type: String
Default: "my-raw-images"
Description: S3 bucket where raw images are uploaded (trigger source)
DestBucketName:
Type: String
Default: "my-processed-images"
Description: S3 bucket where processed variants are saved
Environment:
Type: String
Default: sandbox
Description: Deployment environment name
Resources:
ImageProcessorFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2020
Sourcemap: true
EntryPoints:
- handler.ts
External:
- '@aws-sdk/*'
Properties:
FunctionName: !Sub "ImageProcessorFunction-${Environment}"
CodeUri: ./
Handler: handler.handler
Runtime: nodejs22.x
Timeout: 60
MemorySize: 1769
Environment:
Variables:
SOURCE_BUCKET: !Ref SourceBucketName
DEST_BUCKET: !Ref DestBucketName
Policies:
- Statement:
- Effect: Allow
Action:
- s3:GetObject
Resource: !Sub "arn:aws:s3:::${SourceBucketName}/*"
- Effect: Allow
Action:
- s3:PutObject
Resource: !Sub "arn:aws:s3:::${DestBucketName}/*"
ImageProcessorFunctionS3Permission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt ImageProcessorFunction.Arn
Action: lambda:InvokeFunction
Principal: s3.amazonaws.com
SourceArn: !Sub "arn:aws:s3:::${SourceBucketName}"
SourceAccount: !Ref AWS::AccountId
El MemorySize de 1769MB no es arbitrario: es exactamente 1 vCPU en Lambda. WASM es single-threaded, así que poner más memoria no acelera el procesamiento, pero bajar de ese umbral sí lo frena.
El SDK de S3 se marca como External porque el runtime nodejs22.x ya lo incluye, sin necesidad de empaquetarlo.
El resultado
Una imagen entra al bucket. El pipeline genera:
optimized/: 800px, lista para la página de detallethumbnails/: 200px, lista para el carrusel y las facturas...otros/: variantes adicionales (e.g. 400px, 600px)
Sin intervención manual, sin servidores que mantener.
¿Cuándo usar beautiful-image vs Sharp?
La librería de facto para esto en Node.js es Sharp. Excelente herramienta, pero en Lambda introduce fricción: usa binarios nativos compilados contra libvips (~20MB), esos binarios deben coincidir con la arquitectura exacta de Lambda, y si se despliega el binario equivocado no funcionará en runtime. La documentación oficial de Sharp para Lambda dedica secciones enteras a flags de plataforma, cross-compilación y problemas con symlinks de pnpm. La solución que terminan sugiriendo los docs: usar un Lambda Layer mantenido por la comunidad.
beautiful-image no compite con Sharp en velocidad bruta. Sharp delega a libvips, una librería C con décadas de optimizaciones específicas por arquitectura que WASM no puede igualar.
Si se requiere compositing, SVG rendering, formatos RAW o conversión de espacio de color: Sharp es la herramienta correcta y nada se le acerca. Si se requiere resize y optimización para web con un handful de filtros si que el deploy sea un problema, beautiful-image llega ahí con mucho menos peso y cero fricción operacional.
El código completo con SAM template, evento de prueba y configuración está en examples/lambda-demo
