Serverless image pipeline with aws lambda+node+wasm: from 11.5mb to 91.2kb
DEV Community

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 install is 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.