Serverless image pipeline with aws lambda+node+wasm: from 11.5mb to 91.2kb
The architecture
The flow is simple and completely serverless: 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 on nodejs22.x with x86_64 or arm64 architecture 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 beautiful-image API is chainable and reads like what it does: download, resize, compress, done.
const result = await image(input).resize(800).toJpeg(80)
// result.data Buffer ready to upload
// result.originalSize original size in bytes
// result.optimizedSize new size in bytes
// result.compressionRatio 0.99 = 99% smaller
The SAM template
The entire stack in ~64 lines of YAML. The Lambda fires on any s3:ObjectCreated event in the source bucket:
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
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.
The result
One image lands in the bucket. The pipeline generates:
- optimized/: 800px, ready for the product detail page
- thumbnails/: 200px, ready for the carousel and invoices
- ...others/: additional variants (e.g. 400px, 600px)
No manual intervention, no servers to maintain.
When to use beautiful-image vs Sharp?
The de facto library for this in Node.js is Sharp. Excellent tool, but it introduces friction on Lambda: it uses native binaries compiled against libvips (~20MB), those binaries must match the exact Lambda architecture, and deploying the wrong binary means a runtime failure. The official Sharp docs for Lambda dedicate entire sections to platform flags, cross-compilation, and pnpm symlink issues. The practical solution the docs end up suggesting: use a community-maintained Lambda Layer.
beautiful-image doesn't compete with Sharp on raw speed. Sharp delegates to libvips, a C library with decades of architecture-specific optimizations that WASM can't fully match. If you need compositing, SVG rendering, RAW formats, or color space conversion: Sharp is the right tool and nothing comes close.
If you need resize and web optimization with a handful of filters and want the deploy to be a non-issue, beautiful-image gets you there with a fraction of the weight and zero operational friction.
The full code with SAM template, test event, and configuration is at examples/lambda-demo.
Comments
No comments yet. Start the discussion.