A while back I needed to process thousands of images for an e-commerce platform: multiple versions per product, carousel, thumbnail, invoice miniature. Manual processing wasn't an option. The bash scripts I ran from my machine worked, but they depended on me to run them. The solution was clear: a cloud pipeline that triggered automatically every time a new image arrived.
This post is about how I built that pipeline on AWS Lambda using
No servers to maintain, no workers to scale, no queues to configure for the basic case. One image comes in, two variants come out, ready to serve from CloudFront.
Why WASM changes everything
beautiful-image has a core written in Rust compiled to WASM. That means:
Single artifact: the 469KB WASM binary goes inside the same Lambda ZIP. No Layers, no Docker images.
Real portability: the same package runs onnodejs22.xwithx86_64orarm64architecture without recompiling anything.
Zero native dependencies:npm installis enough. Nothing to compile.
The deploy comes down to three commands:
npm install
sam build
sam deploy
The implementation
The handler is straightforward. It downloads the image from S3, generates the variants with beautiful-image, and uploads them to the destination bucket:
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`)
}
}
The MemorySize of 1769MB is not arbitrary: it's exactly 1 vCPU on Lambda. WASM is single-threaded, so adding more memory doesn't speed up processing, but going below that threshold does slow it down.
The S3 SDK is marked as External because the nodejs22.x runtime already includes it, so there's no need to bundle it.
SOCIAL SHARE CARD GENERATOR