Andrew Gilliland
Back to Articles

Real-Time Image Optimization with Lambda on S3 Upload

If you want images optimized automatically as users or systems upload them, use a Lambda function triggered by S3 events. This approach works great for user-generated content, dynamic assets, or any workflow where you can’t predict uploads in advance.

What This Approach Solves

  • No manual trigger needed — optimization happens immediately on upload
  • Works for user-uploaded images without server-side involvement
  • Supports dynamic workflows where source images aren’t pre-planned
  • Keeps original and optimized versions for fallback
  • Scales automatically with Lambda

Architecture

┌──────────────┐
│  User/App    │
└──────┬───────┘

       │ uploads image

┌──────────────────────────────────┐
│ S3 Bucket (source-images/)       │
│  └─ image.jpg                    │
└──────┬───────────────────────────┘

       │ S3:ObjectCreated event

┌──────────────────────────────────┐
│ Lambda Function                  │
│  └─ optimize-image               │
└──────┬───────────────────────────┘

       │ downloads & processes

┌──────────────────────────────────┐
│ S3 Bucket (optimized-images/)    │
│  ├─ image.abc123-480.webp        │
│  ├─ image.abc123-768.webp        │
│  ├─ image.abc123-1200.webp       │
│  └─ image.abc123.avif            │
└──────────────────────────────────┘

       │ served via CloudFront

    End User

CDK Stack Setup

Create lib/optimize-image-stack.ts:

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3Notifications from "aws-cdk-lib/aws-s3-notifications";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Duration } from "aws-cdk-lib";

export class OptimizeImageStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Source bucket: where users upload raw images
    const sourceBucket = new s3.Bucket(this, "SourceBucket", {
      bucketName: "images-source-uploads",
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true,
    });

    // Output bucket: where optimized images live
    const optimizedBucket = new s3.Bucket(this, "OptimizedBucket", {
      bucketName: "images-optimized-cdn",
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    // Lambda function to optimize images
    const optimizeFunction = new NodejsFunction(this, "OptimizeImage", {
      entry: "lambda/optimize-image.ts",
      handler: "handler",
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: Duration.seconds(120),
      memorySize: 3008, // Sharp needs memory for image processing
      ephemeralStorageSize: cdk.Size.mebibytes(10240),
      environment: {
        OPTIMIZED_BUCKET: optimizedBucket.bucketName,
      },
    });

    // Grant Lambda permissions
    sourceBucket.grantRead(optimizeFunction);
    optimizedBucket.grantWrite(optimizeFunction);

    // Trigger Lambda on S3 upload
    sourceBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED,
      new s3Notifications.LambdaDestination(optimizeFunction),
    );

    // Output the bucket names
    new cdk.CfnOutput(this, "SourceBucketName", {
      value: sourceBucket.bucketName,
    });

    new cdk.CfnOutput(this, "OptimizedBucketName", {
      value: optimizedBucket.bucketName,
    });
  }
}

Deploy with:

cdk deploy

Lambda Function

Create lambda/optimize-image.ts:

import {
  S3Client,
  GetObjectCommand,
  PutObjectCommand,
} from "@aws-sdk/client-s3";
import sharp from "sharp";
import { createHash } from "crypto";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const OPTIMIZED_BUCKET = process.env.OPTIMIZED_BUCKET!;

const WIDTHS = [480, 768, 1200, 1600];
const QUALITY = { webp: 78, avif: 55 };

export async function handler(event: any) {
  try {
    const bucket = event.Records[0].s3.bucket.name;
    const key = decodeURIComponent(event.Records[0].s3.object.key);

    console.log(`Processing ${key} from ${bucket}`);

    // Download the uploaded image
    const { Body, ContentType } = await s3.send(
      new GetObjectCommand({ Bucket: bucket, Key: key }),
    );

    if (!Body) throw new Error("No image body");

    const imageBuffer = await Body.transformToByteArray();
    const filename = key
      .split("/")
      .pop()!
      .replace(/\.[^.]+$/, "");

    // Verify it's an image
    const supportedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!supportedTypes.includes(ContentType || "")) {
      console.log(`Skipping ${key}: unsupported type ${ContentType}`);
      return { statusCode: 400, body: "Unsupported image type" };
    }

    // Get original dimensions
    const metadata = await sharp(imageBuffer).metadata();
    console.log(
      `Original: ${metadata.width}x${metadata.height} (${ContentType})`,
    );

    const results: any[] = [];

    // Generate variants
    for (const width of WIDTHS) {
      // Skip if original is smaller than target width
      if ((metadata.width || 0) < width) {
        console.log(`Skipping ${width}px (original smaller)`);
        continue;
      }

      // WebP variant
      const webpBuffer = await sharp(imageBuffer)
        .resize(width, width, { fit: "cover", withoutEnlargement: true })
        .webp({ quality: QUALITY.webp })
        .toBuffer();

      const webpHash = createHash("sha256")
        .update(webpBuffer)
        .digest("hex")
        .slice(0, 8);
      const webpKey = `optimized/${filename}.${webpHash}-${width}.webp`;

      await s3.send(
        new PutObjectCommand({
          Bucket: OPTIMIZED_BUCKET,
          Key: webpKey,
          Body: webpBuffer,
          ContentType: "image/webp",
          CacheControl: "public,max-age=31536000,immutable",
        }),
      );

      results.push({
        format: "webp",
        width,
        size: webpBuffer.length,
        key: webpKey,
      });
      console.log(`✓ ${webpKey} (${webpBuffer.length} bytes)`);

      // AVIF variant
      const avifBuffer = await sharp(imageBuffer)
        .resize(width, width, { fit: "cover", withoutEnlargement: true })
        .avif({ quality: QUALITY.avif })
        .toBuffer();

      const avifHash = createHash("sha256")
        .update(avifBuffer)
        .digest("hex")
        .slice(0, 8);
      const avifKey = `optimized/${filename}.${avifHash}-${width}.avif`;

      await s3.send(
        new PutObjectCommand({
          Bucket: OPTIMIZED_BUCKET,
          Key: avifKey,
          Body: avifBuffer,
          ContentType: "image/avif",
          CacheControl: "public,max-age=31536000,immutable",
        }),
      );

      results.push({
        format: "avif",
        width,
        size: avifBuffer.length,
        key: avifKey,
      });
      console.log(`✓ ${avifKey} (${avifBuffer.length} bytes)`);
    }

    // Copy original as fallback
    const originalKey = `optimized/${filename}-original${key.substring(
      key.lastIndexOf("."),
    )}`;
    await s3.send(
      new PutObjectCommand({
        Bucket: OPTIMIZED_BUCKET,
        Key: originalKey,
        Body: imageBuffer,
        ContentType: ContentType,
        CacheControl: "public,max-age=31536000,immutable",
      }),
    );
    console.log(`✓ ${originalKey} (original)`);

    return {
      statusCode: 200,
      body: JSON.stringify({
        source: key,
        optimized: results,
        original: originalKey,
      }),
    };
  } catch (err) {
    console.error("Error:", err);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: String(err) }),
    };
  }
}

Lambda Layer for Sharp

Sharp is a native module that needs to be bundled correctly. Create a layer:

mkdir -p layers/sharp-layer/nodejs
cd layers/sharp-layer/nodejs
npm init -y
npm install sharp
cd ../..
zip -r sharp-layer.zip sharp-layer
aws lambda publish-layer-version \
  --layer-name sharp \
  --zip-file fileb://sharp-layer.zip \
  --compatible-runtimes nodejs22.x

Then reference in CDK:

const sharpLayer = lambda.LayerVersion.fromLayerVersionArn(
  this,
  "SharpLayer",
  `arn:aws:lambda:${this.region}:${this.account}:layer:sharp:1`,
);

const optimizeFunction = new NodejsFunction(this, "OptimizeImage", {
  // ... other config
  layers: [sharpLayer],
});

Or use a simpler approach with @aws-lambda-powertools/commons:

const optimizeFunction = new NodejsFunction(this, "OptimizeImage", {
  entry: "lambda/optimize-image.ts",
  bundling: {
    externalModules: ["sharp"], // Don't bundle, use layer
  },
  layers: [
    lambda.LayerVersion.fromLayerVersionArn(
      this,
      "SharpLayer",
      `arn:aws:lambda:${this.region}:${this.account}:layer:sharp:1`,
    ),
  ],
});

Using Optimized Images in React

Create a hook to query the optimized bucket:

import React from "react";

interface ImageVariant {
  format: "webp" | "avif";
  width: number;
  key: string;
}

interface OptimizedImageProps {
  imageKey: string;
  alt: string;
  className?: string;
}

export function OptimizedImage({
  imageKey,
  alt,
  className,
}: OptimizedImageProps) {
  const assetBase = "https://images-optimized.example.com";
  const [variants, setVariants] = React.useState<ImageVariant[]>([]);

  React.useEffect(() => {
    // Query S3 API Gateway or Lambda to list variants for this image
    fetch(`/api/images/${imageKey}`)
      .then((r) => r.json())
      .then((data) => setVariants(data.variants));
  }, [imageKey]);

  const webpVariants = variants.filter((v) => v.format === "webp");
  const avifVariants = variants.filter((v) => v.format === "avif");
  const fallback = webpVariants[webpVariants.length - 1];

  if (!fallback) return null;

  return (
    <picture>
      {avifVariants.length > 0 && (
        <source
          type="image/avif"
          srcSet={avifVariants
            .map((v) => `${assetBase}/${v.key} ${v.width}w`)
            .join(", ")}
        />
      )}
      {webpVariants.length > 0 && (
        <source
          type="image/webp"
          srcSet={webpVariants
            .map((v) => `${assetBase}/${v.key} ${v.width}w`)
            .join(", ")}
        />
      )}
      <img
        src={`${assetBase}/${fallback.key}`}
        alt={alt}
        className={className}
        loading="lazy"
      />
    </picture>
  );
}

CI/CD: Upload Images to S3

In your workflow, upload images directly to the source bucket:

- name: Upload images to S3
  run: |
    aws s3 sync ./source-images s3://images-source-uploads/ \
      --delete

Lambda handles the rest automatically.

Costs

Lambda on S3 upload costs roughly:

  • Duration: ~10-15s per image (depends on size)
  • Memory: 3 GB allocated
  • Per image: $0.0000333 per second × 12 seconds = ~$0.0004 per image
  • Requests: $0.20 per million invocations

For 10,000 images/month: ~$4-5/month in Lambda costs + S3 storage.

When to Use This Approach

Scenario Batch Lambda on Upload
Editorial images ✅ Simpler Overkill
User uploads ❌ Manual ✅ Perfect
Dynamic content ❌ Manual ✅ Perfect
High volume ⚠️ Rate limited ✅ Scales
Cost sensitive ✅ Pay per run ⚠️ Pay per image

Use Lambda on S3 upload when images arrive unpredictably or users control the uploads.

Monitoring

Add CloudWatch insights to Lambda:

const optimizeFunction = new NodejsFunction(this, "OptimizeImage", {
  // ... other config
  logRetention: logs.RetentionDays.ONE_WEEK,
});

// CloudWatch alarm for errors
new cloudwatch.Alarm(this, "OptimizeErrorAlarm", {
  metric: optimizeFunction.metricErrors(),
  threshold: 5,
  evaluationPeriods: 1,
  alarmDescription: "Alert on image optimization failures",
});

This keeps your image pipeline automated and ready for dynamic content.

Related Articles

Table of Contents