Written by Mateus Alexandre, DevOps Engineer at TrackIt

The Challenge of Balancing Image Quality and Performance in Edge Delivery

Modern websites rely heavily on rich imagery to drive engagement, but delivering optimized visuals without compromising performance remains a challenge, especially when content must adapt to different devices and network conditions. Many platforms seek to address this at the edge, where latency and bandwidth impact user experience most.

Translating Fastly Image Optimization to CloudFront and Lambda@Edge

To meet these demands, implementing dynamic image optimization at the edge is critical. While Fastly Image Optimizer (FIO) has been a popular solution for this, Amazon CloudFront, combined with Lambda@Edge, offers a viable alternative.

Outlined below are three practical image optimization strategies—originally achieved through Fastly Image Optimizer (FIO)— implemented within Amazon CloudFront using Lambda@Edge. Although CloudFront does not natively support features like dynamic format conversion, on-the-fly resizing, or conditional compression based on browser headers, these behaviors can be replicated effectively using Lambda@Edge and the Sharp library. Each example includes the Fastly VCL logic alongside a functional equivalent built entirely within the AWS ecosystem.

Scenario 1: Dynamic Format with Compatibility Fallback

Objective

Reduce the size of images delivered to end-users by dynamically generating the most efficient format supported by the browser (such as AVIF, WebP, or JPEG XL), without needing to store multiple versions at the origin.

Use Case

This approach is ideal for high-traffic websites with large visual elements (such as banners or hero sections), where optimizing performance and bandwidth is essential. Modern browsers will receive next-gen formats, while older browsers will still receive JPEG or PNG images.

React Code

This code displays a standard .png image. The edge (whether using Fastly or CloudFront) will automatically convert it to AVIF or WebP if the browser supports these formats.

function FormatTest() {
    return (
      <section>
        <h2>1. Dynamic Format Test (WebP/AVIF)</h2>
        <img src=”/images/sec1.png”/>
      </section>
    );
  }
 
  export default FormatTest;

Using Fastly

Behavior

Fastly reads the browser’s Accept header and serves the most compatible image format without requiring any front-end code. In this setup, it is assumed that developers will not manually include ?format=auto. The VCL (Varnish Configuration Language) logic automatically injects it — but only if a format parameter doesn’t already exist.

Requested URL Example

 /images/banner.jpg

VCL applied

sub vcl_recv {
  if (req.url.ext ~ “(?i)(jpg|jpeg|png)”) {
    set req.http.x-fastly-imageopto-api = “fastly”;

    # Only add format=auto if no existing format parameter is found
    if (req.url.qs !~ “(^|&)format=”) {
      if (std.strlen(req.url.qs) > 0) {
        set req.url = req.url + “&format=auto”;
      } else {
        set req.url = req.url + “?format=auto”;
      }
    }
  }
}

Using CloudFront

CloudFront doesn’t natively support dynamic image format conversion like Fastly’s Image Optimizer. However, this behavior can be replicated by attaching a Lambda@Edge function to the distribution. The function intercepts image requests, checks the browser’s Accept header, and if AVIF or WebP is supported, it fetches the original image from S3, converts it on-the-fly using the Sharp library, and returns the optimized image. If the browser does not support either format, the original image is served unchanged from the origin.

What You’ll Need

  • A CloudFront distribution routing requests like /images/*
  • An S3 bucket containing your original images (e.g., .jpg, .png) under /images/ path
  • A Lambda function deployed in us-east-1
  • A deployment package containing your code, sharp, and aws-sdk

Steps to Deploy

  • Create the Lambda function
    • Region: us-east-1
    • Runtime: Node.js 20.x
  • Configure IAM Permissions

Attach the following two inline policies to the Lambda’s execution role and update the trust policy:

  • Access to get objects from the S3 bucket:
{
    “Version”: “2012-10-17”,
    “Statement”: [
        {
            “Sid”: “Statement1”,
            “Effect”: “Allow”,
            “Action”: [
                “s3:GetObject”
            ],
            “Resource”: [
                “arn:aws:s3:::BUCKET_NAME”,
                “arn:aws:s3:::BUCKET_NAME/*”
            ]
        }
    ]
}
  • CloudWatch logging permissions:
{
    “Version”: “2012-10-17”,
    “Statement”: [
        {
            “Effect”: “Allow”,
            “Action”: [
                “logs:CreateLogGroup”,
                “logs:CreateLogStream”,
                “logs:PutLogEvents”
            ],
            “Resource”: [
                “arn:aws:logs:*:*:*”
            ]
        }
    ]
}
  • Update the Trusted entities to let the edgelambda assume the role:
{
    “Version”: “2012-10-17”,
    “Statement”: [
        {
            “Effect”: “Allow”,
            “Principal”: {
                “Service”: [
                    “lambda.amazonaws.com”,
                    “edgelambda.amazonaws.com”
                ]
            },
            “Action”: “sts:AssumeRole”
        }
    ]
}

Prepare the Lambda Package

  • Create your project folder with the following files:
    • index.jsx (code below)

‘use strict’;

const AWS = require(‘aws-sdk’);
const sharp = require(‘sharp’);

const s3 = new AWS.S3();

exports.handler = async (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;
  const accept = request.headers[‘accept’]?.[0]?.value || ”;

  let targetFormat = null;

  if (accept.includes(‘image/avif’)) {
    targetFormat = ‘avif’;
  } else if (accept.includes(‘image/webp’)) {
    targetFormat = ‘webp’;
  }

  if (!targetFormat) {
    return callback(null, request);
  }

  const bucket = ‘YOUR_BUCKET_NAME’;
  const key = uri.startsWith(‘/’) ? uri.slice(1) : uri;

  try {
    const s3Response = await s3.getObject({ Bucket: bucket, Key: key }).promise();

    const buffer = await sharp(s3Response.Body)
      .toFormat(targetFormat)
      .toBuffer();

    const response = {
      status: ‘200’,
      statusDescription: ‘OK’,
      headers: {
        ‘content-type’: [{ key: ‘Content-Type’, value: `image/${targetFormat}` }],
        ‘cache-control’: [
          {
            key: ‘Cache-Control’,
            value: ‘max-age=3600’,
          },
        ],
      },
      bodyEncoding: ‘base64’,
      body: buffer.toString(‘base64’),
    };

    return callback(null, response);
  } catch (error) {
    console.error(`Error processing image “${key}”:`, error);

    return callback(null, {
      status: ‘500’,
      statusDescription: ‘Internal Server Error’,
      body: ‘Image processing failed.’,
    });
  }
};
  • Inside the folder, run:
npm install –cpu=x64 –os=linux aws-sdk
npm install –cpu=x64 –os=linux sharp 

  • Zip the following:
    • ​​index.js
    • node_modules/

Deploy Lambda@Edge

  • Upload the zip package to Lambda (region us-east-1)
  • Publish a new version of the function
  • Go to CloudFront > Behaviors
    • Add or edit the behavior for /images/*
    • Set Origin to your S3 bucket
    • In Origin Request Policy, use a policy that includes the Accept header. Create a custom policy if needed.
    • Under Function Associations, set:
      • Event Type: Origin Request
      • Lambda ARN: use the full ARN with version suffix (ARN:<version>)
      • Include Body: unchecked

Test the Behavior

  • Example: https://your-cloudfront-domain/images/banner.jpg
  • If your browser supports WebP or AVIF, the image will be dynamically converted and returned in that format.

View Lambda Logs

  • Go to CloudFront > Monitoring > Telemetry
  • Select the Lambda@Edge tab and then click on the function
  • Click on View function logs
  • Choose the region closest to where the edge was invoked
  • Tip: You can find the executing region by checking the “Function metrics” chart. Lambda@Edge logs are region-specific.

Scenario 2: 200×200 Thumbnails with Route Redirection

Objective

Serve resized 200×200 thumbnails through a clean and user-friendly /thumbs/ path, while hiding the actual origin structure (/images/). The transformation is done entirely at the edge, ensuring consistent thumbnail dimensions, improving performance, and centralizing optimization logic without requiring any front-end intervention.

Use Case

An e-commerce platform needs to display product thumbnails across search and category result pages. Although the original images are stored under /images/, the front-end references them using /thumbs/ for clarity and consistency. By applying resizing logic at the edge, developers avoid repeating transformation logic across the UI and ensure consistent delivery of optimized, lightweight images.

React Code

Displays an image from the /thumbs/ path. The edge (Fastly or CloudFront) rewrites the URL to fetch from /images/ and resizes it to a 200×200 thumbnail.

function ThumbnailTest() {
    return (
      <section>
        <h2>2. Thumbnail Test (200×200)</h2>
        <img src=”/thumbs/sec2.jpg”/>
      </section>
    );
  }
 
  export default ThumbnailTest;

Using Fastly

Behavior

When a request is made to any URL starting with /thumbs/, the VCL first rewrites the request path by replacing /thumbs with /images, ensuring it targets the correct origin location. Fastly Image Optimizer (FIO) then applies resizing parameters (width=200&height=200&fit=cover) to generate a 200×200 square thumbnail, cropped and optimized directly at the edge. This approach keeps the origin structure hidden and eliminates the need for image manipulation on the front-end.

Requested URL example

/thumbs/product123.jpg

VCL applied

sub vcl_recv {
  if (req.url.path ~ “^/thumbs/”) {
    set req.http.x-fastly-imageopto-api = “fastly”;

    # Replace /thumbs/… with /images/…
    set req.url = regsub(req.url, “^/thumbs”, “/images”);

    # Add parameters for 200×200 square thumbnail with center cropping
    if (std.strlen(req.url.qs) > 0) {
      set req.url = req.url + “&width=200&height=200&fit=cover”;
    } else {
      set req.url = req.url + “?width=200&height=200&fit=cover”;
    }
  }
}

Using CloudFront

CloudFront does not natively support URL rewriting or image resizing, but this can be replicated using Lambda@Edge. In this scenario, the goal is to serve 200×200 thumbnails when a request starts with /thumbs/, while concealing the actual origin path (/images/). 

The Lambda@Edge function rewrites the URL from /thumbs/ to /images/, retrieves the original image from S3, resizes it to 200×200 with center cropping using Sharp, and returns the optimized image. The result is cached at the CloudFront edge using the Cache-Control header, ensuring that the Lambda function is only executed on the first request. Subsequent identical requests are served directly from the cache.

What You’ll Need

  • A CloudFront distribution routing requests like /thumbs/*
  • An S3 bucket with original images under the /images/ path
  • A Lambda function deployed to us-east-1
  • A deployment package containing your code, sharp, and aws-sdk

Steps to Deploy

Prepare the Lambda Package

  • Create your project folder with the following files:
    • index.jsx (code below)
‘use strict’;

const AWS = require(‘aws-sdk’);
const sharp = require(‘sharp’);

const s3 = new AWS.S3();

exports.handler = async (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;

  // Only handle paths that start with /thumbs/
  if (!uri.startsWith(‘/thumbs/’)) {
    return callback(null, request);
  }

  // Rewrite path from /thumbs/… to /images/…
  const rewrittenUri = uri.replace(/^\/thumbs/, ‘/images’);

  const bucket = ‘YOUR_BUCKET_NAME’;
  const key = rewrittenUri.startsWith(‘/’) ? rewrittenUri.slice(1) : rewrittenUri;

  try {
    // Fetch the original image from S3
    const s3Response = await s3.getObject({ Bucket: bucket, Key: key }).promise();

    // Resize the image to 200×200 with center crop
    const buffer = await sharp(s3Response.Body)
      .resize(200, 200, { fit: ‘cover’ })
      .toBuffer();

    // Return the resized image with proper headers
    const response = {
      status: ‘200’,
      statusDescription: ‘OK’,
      headers: {
        ‘content-type’: [{ key: ‘Content-Type’, value: s3Response.ContentType || ‘image/jpeg’ }],
        ‘cache-control’: [
          {
            key: ‘Cache-Control’,
            value: ‘max-age=3600’, // Image will be cached in CloudFront edge for 1 hour
          },
        ],
      },
      bodyEncoding: ‘base64’,
      body: buffer.toString(‘base64’),
    };

    return callback(null, response);
  } catch (error) {
    console.error(`Error processing image “${key}”:`, error);

    // If an error occurs, return a 500 response
    return callback(null, {
      status: ‘500’,
      statusDescription: ‘Internal Server Error’,
      body: ‘Thumbnail processing failed.’,
    });
  }
};
  • Inside the folder, run:
npm install –cpu=x64 –os=linux aws-sdk
npm install –cpu=x64 –os=linux sharp 

  • Zip the following:
    • ​​index.js
    • node_modules/

Deploy Lambda@Edge

  • Upload the zip package to Lambda (region us-east-1)
  • Publish a new version of the function
  • Go to CloudFront > Behaviors
    • Add or edit the behavior for /thumbs/*
    • Set Origin to your S3 bucket
    • Under Function Associations, set:
      • Event Type: Origin Request
      • Lambda ARN: use the full ARN with version suffix (ARN:<version>)
      • Include Body: unchecked

Test the Behavior

  • Example: https://your-cdn-url/thumbs/product123.jpg
  • The Lambda rewrites to /images/product123.jpg, resizes it, and returns a 200×200 thumbnail, which is cached for 1 hour

View Lambda Logs

Scenario 3: Low-Quality Placeholder (LQIP) with Lazy Loading

Objective

Enhance perceived loading performance by serving a lightweight, blurred version of the image initially. As the user scrolls and the image enters the viewport, it is seamlessly replaced with the high-resolution version. This technique improves the browsing experience without requiring manual interaction.

Use Case

Well-suited for content-heavy pages such as blogs, news sites, or portfolios that display multiple images during scroll. By loading a small, blurred placeholder first, users see content sooner. As they reach each image, the full-resolution version loads in its place, reducing data usage and improving performance for users on slower or metered connections.

React Code

The component below demonstrates a Low-Quality Image Placeholder (LQIP) implementation in React. It initially loads a blurred, compressed image from the /lowQuality/ path. Once 50% or more of the image enters the viewport, the browser starts downloading the full-resolution image from the /images/ path, which automatically replaces the placeholder upon load, creating a smooth visual transition.

import { useEffect, useRef, useState } from “react”;

const lowQualityImage = “/lowQuality/sec3.jpg”;
const highQualityImage = “/images/sec3.jpg”;

function LQIPTest() {
  const imgRef = useRef(null);
  const [src, setSrc] = useState(lowQualityImage);

  useEffect(() => {
    const imgElement = imgRef.current;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          const highResImage = new Image();
          highResImage.src = highQualityImage;

          highResImage.onload = () => setSrc(highResImage.src);

          observer.disconnect();
        }
      },
      { threshold: 0.5 } // trigger when 50% of the image is visible
    );

    if (imgElement) observer.observe(imgElement);

    return () => observer.disconnect();
  }, []);

  return (
    <section>
      <h2>3. LQIP Lazy Load Test</h2>
      <img
        ref={imgRef}
        src={src}
        alt=”Progressive LQIP”
      />
      <p>This image starts blurry and loads the full version on scroll.</p>
    </section>
  );
}

export default LQIPTest;

Using Fastly

Behavior

When a request targets a path under /lowQuality/, Fastly automatically applies Image Optimizer (FIO) parameters—such as reduced quality and blur—to generate a lightweight placeholder. This version loads quickly, providing immediate visual feedback to the user.

On the front end, a React component monitors when the image enters the viewport. At that point, it replaces the placeholder with the full-resolution version. If the high-quality image is already cached, the transition happens instantly and without delay.

Requested URL example

/lowQuality/banner.jpg

VCL applied

sub vcl_recv {
  if (req.url.path ~ “^/lowQuality/”) {    set req.http.x-fastly-imageopto-api = “fastly”;
    # Replace /lowQuality/… with /images/…    set req.url = regsub(req.url, “^/lowQuality”, “/images”);
    # Add parameters for lowering quality and adding blur    if (std.strlen(req.url.qs) > 0) {      set req.url = req.url + “&quality=20&blur=40”;    } else {      set req.url = req.url + “?quality=20&blur=40”;    }  }
}

Using CloudFront

CloudFront does not offer native support for low-quality image placeholders or lazy loading. However, this functionality can be replicated through a Lambda@Edge function.

The function intercepts requests to a specific path (such as /lowQuality/), retrieves the corresponding original image from S3, and applies compression and blur effects using the Sharp library. The transformed image is then returned to the user. When integrated with front-end lazy loading techniques, this setup effectively enables Low-Quality Image Placeholder (LQIP) behavior within the CloudFront ecosystem.

What You’ll Need

  • A CloudFront distribution routing requests like (e.g., /lowQuality/*)
  • An S3 bucket with original images under /images/
  • A Lambda@Edge function deployed in us-east-1
  • Sharp and aws-sdk included in the function package

Steps to deploy

Prepare the Lambda Package

  • Create your project folder with the following files:
    • index.jsx (code below)
‘use strict’;
const AWS = require(‘aws-sdk’);const sharp = require(‘sharp’);
const s3 = new AWS.S3();
exports.handler = async (event, context, callback) => {  const request = event.Records[0].cf.request;  const uri = request.uri;
  // Only handle paths that start with /lowQuality/  if (!uri.startsWith(‘/lowQuality/’)) {    return callback(null, request);  }
  // Rewrite path from /lowQuality/… to /images/…  const rewrittenUri = uri.replace(/^\/lowQuality/, ‘/images’);
  const bucket = ‘image-optimization-cdn-test’;  const key = rewrittenUri.startsWith(‘/’) ? rewrittenUri.slice(1) : rewrittenUri;
  try {    // Fetch the original image from S3    const s3Response = await s3.getObject({ Bucket: bucket, Key: key }).promise();
    const buffer = await sharp(s3Response.Body)      .jpeg({ quality: 20 })      .blur(20)      .toBuffer();
    const response = {      status: ‘200’,      statusDescription: ‘OK’,      headers: {        ‘content-type’: [{ key: ‘Content-Type’, value: ‘image/jpeg’ }],        ‘cache-control’: [          {            key: ‘Cache-Control’,            value: ‘max-age=3600’, // Image will be cached in CloudFront edge for 1 hour          },        ],      },      bodyEncoding: ‘base64’,      body: buffer.toString(‘base64’),    };
    return callback(null, response);  } catch (error) {    console.error(`Error processing image “${key}”:`, error);
    // If an error occurs, return a 500 response    return callback(null, {      status: ‘500’,      statusDescription: ‘Internal Server Error’,      body: ‘LQIP processing failed.’,    });  }};
  • Inside the folder, run:
npm install –cpu=x64 –os=linux aws-sdk
npm install –cpu=x64 –os=linux sharp 
  • Zip the following:
    • ​​index.js
    • node_modules/

Deploy Lambda@Edge

  • Upload the zip package to Lambda (region us-east-1)
  • Publish a new version of the function
  • Go to CloudFront > Behaviors
    • Add or edit the behavior for /lowQuality/*
    • Set Origin to your S3 bucket
    • Under Function Associations, set:
      • Event Type: Origin Request
      • Lambda ARN: use the full ARN with version suffix (ARN:<version>)
      • Include Body: unchecked

Test the Behavior

  • Example: https://your-cloudfront-domain/lowQuality/banner.jpg
  • Your browser will initially display a blurred and compressed version of the image. Once 50% of the image becomes visible in the viewport during scroll, it will seamlessly swap to the full-resolution version.

View Lambda Logs

Note About CloudFront Functions

The implementations above rely on CloudFront with Lambda@Edge and Sharp to perform real-time image processing. In cases where image variants are pre-generated—during a build step or at upload time—CloudFront Functions can be used to rewrite URLs and route requests to the appropriate version. Although this approach does not perform transformations at the edge, it delivers the same visual result to end-users with lower latency and reduced operational overhead.

Conclusion

Fastly provides native image transformation capabilities via its Image Optimizer (FIO), enabling powerful optimizations at the edge. Comparable results can be achieved in the AWS ecosystem by combining CloudFront with Lambda@Edge and the Sharp library. This setup supports dynamic format conversion, resizing, and compression—even without built-in image processing features. For use cases where image variants already exist, CloudFront Functions present a lightweight, cost-effective solution for handling routing and rewrites efficiently. Together, these approaches demonstrate that CloudFront is capable of delivering performant, modern image experiences similar to those offered by specialized CDNs.

About TrackIt

TrackIt is an international AWS cloud consulting, systems integration, and software development firm headquartered in Marina del Rey, CA.

We have built our reputation on helping media companies architect and implement cost-effective, reliable, and scalable Media & Entertainment workflows in the cloud. These include streaming and on-demand video solutions, media asset management, and archiving, incorporating the latest AI technology to build bespoke media solutions tailored to customer requirements.

Cloud-native software development is at the foundation of what we do. We specialize in Application Modernization, Containerization, Infrastructure as Code and event-driven serverless architectures by leveraging the latest AWS services. Along with our Managed Services offerings which provide 24/7 cloud infrastructure maintenance and support, we are able to provide complete solutions for the media industry.