DEV Community

Cover image for Serverless Lazy Generation: generate once, cache forever with CloudFront origin groups (+ S3 and Lambda)

Serverless Lazy Generation: generate once, cache forever with CloudFront origin groups (+ S3 and Lambda)

Sometimes, your business app may have to be ready to serve millions of assets generated from your data (invoice PDFs, text-to-speech audio files, etc.), only a small fraction of which will actually be requested by users.

When such assets generation is expensive, the naive approach - pre-generate every asset - leads to a lot of wasted compute: you generate assets that may never be requested.

Pre-generation may also cause bottlenecks (adding 1,000 new articles means synthesizing 1,000 audio files before they're available), storage bloat (you're paying to store assets that may never see a single request), and staleness (when content changes, you need a pipeline to detect and regenerate affected assets).

What if you could flip this model? Generate assets only when requested, cache them permanently, and never generate the same asset twice.

In this post, I'll walk through a complete serverless architecture that uses CloudFront's native failover mechanism to implement lazy audio generation with Amazon Polly. The same pattern applies to image thumbnails, PDF rendering, video transcoding, or any expensive asset generation.

Solution Architecture

Architecture Overview: The CloudFront Origin Group Pattern

The key insight is that CloudFront Origin Groups support automatic failover. You configure a primary origin (S3) and a failover origin (API Gateway + Lambda). When the primary returns a 403 or 404, CloudFront transparently routes the request to the failover origin: no client-side retry, no custom logic.

This gives us a clean separation:

Component Role
S3 (primary origin) Serves cached audio files. Acts as a permanent cache.
API Gateway + Lambda (failover origin) Generates audio on-demand when S3 returns 404.
Amazon Polly Converts text to speech (the expensive operation).
CloudFront Edge caching + origin failover orchestration.

The beauty is that once Lambda generates an audio file and stores it in S3, all subsequent requests are served directly from S3 (or CloudFront's edge cache). Lambda never executes again for that asset.

How It Works

(full-disclosure: from this point onwards, the post is AI-generated)

First Request (Cache Miss)

Sequence diagram

  1. Client requests https://d1234.cloudfront.net/audio/welcome.mp3
  2. CloudFront checks its edge cache — miss. Routes to the Origin Group.
  3. S3 (primary origin) returns 404 — the file doesn't exist yet.
  4. CloudFront detects the 404 and fails over to the secondary origin.
  5. API Gateway receives the request and invokes Lambda.
  6. Lambda reads the source text from s3://bucket/texts/welcome.txt.
  7. Lambda calls Amazon Polly to synthesize speech from the text.
  8. Lambda stores the generated MP3 at s3://bucket/audio/welcome.mp3.
  9. Lambda returns the MP3 binary (base64-encoded) with Cache-Control: public, max-age=31536000.
  10. CloudFront caches the response at the edge and returns it to the client.

Second Request (Cache Hit)

  1. Client requests https://d1234.cloudfront.net/audio/welcome.mp3
  2. CloudFront checks its edge cache — hit. Returns immediately.

If the edge cache has expired:

  1. CloudFront routes to the Origin Group.
  2. S3 (primary origin) returns 200 — the file exists.
  3. CloudFront caches and returns the response. Lambda is never invoked.

Key Components

Below I detail the components of the solution I share in my sample GitHub repo. My use case is for Text-to-Speech generation with Polly, but you can adapt it to your own.

CloudFront Origin Group

The Origin Group is the orchestration layer. It defines:

  • A primary origin (S3 with OAC) for serving cached content
  • A failover origin (API Gateway) for on-demand generation
  • Failover criteria: HTTP 403 and 404 status codes

This is infrastructure-level routing — no application code needed to handle caching logic.

S3 as a Permanent Cache

S3 serves dual purpose:

  • Source storage: Text files live at texts/{filename}.txt
  • Cache storage: Generated audio lives at audio/{filename}.mp3

The S3 bucket is not publicly accessible. CloudFront accesses it via Origin Access Control (OAC), and Lambda accesses it via IAM role permissions.

Lambda as the Generator

The Lambda function is lean — its only job is:

  1. Read the source text from S3
  2. Call Polly to synthesize audio
  3. Store the result back in S3
  4. Return the audio to the caller

It includes a belt-and-suspenders check: before calling Polly, it verifies the audio doesn't already exist in S3 (handling race conditions where multiple simultaneous first-requests hit Lambda).

Amazon Polly for TTS

Polly provides neural and standard voices for text-to-speech synthesis. In this architecture, we use the standard engine with the Joanna voice and MP3 output format. Polly charges per character synthesized — another reason lazy generation saves money.

Benefits

Zero cold starts for cached content. Once generated, audio is served from S3 or CloudFront's edge — sub-10ms latency worldwide. Lambda cold starts only affect the very first request for each asset.

Infinite scalability. S3 and CloudFront scale to any traffic level without configuration. The generation layer (Lambda) only handles the small fraction of requests that are cache misses.

Pay only for generation once. Each audio file is synthesized exactly once. After that, you're paying only S3 storage ($0.023/GB/month) and CloudFront data transfer.

CloudFront edge caching. Popular content is served from 400+ edge locations worldwide. Your users get sub-50ms response times regardless of where the origin bucket lives.

Zero maintenance. No cron jobs, no batch pipelines, no cache invalidation logic. The system is self-healing — if you delete a cached file, it regenerates on next request.

Cost Analysis

Let's compare pre-generation vs. lazy generation for a platform with 10,000 text articles:

Pre-Generation Approach

Item Cost
Polly: 10,000 articles × 2,000 chars avg $16.00 (standard voice)
S3 storage: 10,000 × 500KB avg $0.12/month
Lambda compute: 10,000 invocations × 5s avg $0.83
Total upfront $16.95

You pay this every time content changes and you re-run the batch.

Lazy Generation Approach (assuming 20% of content is ever requested)

Item Cost
Polly: 2,000 articles × 2,000 chars avg $3.20
S3 storage: 2,000 × 500KB avg $0.02/month
Lambda compute: 2,000 invocations × 5s avg $0.17
CloudFront requests (all 10K articles, cached) $0.01
Total $3.40

80% cost reduction — and you never pay for content nobody reads.

Code Walkthrough

Lambda Handler Highlights

import json
import re
import os
import base64
import boto3

s3 = boto3.client("s3")
polly = boto3.client("polly")
BUCKET = os.environ["BUCKET_NAME"]

def handler(event, context):
    path = event.get("path", "")
    match = re.search(r"/audio/(.+)\.mp3$", path)
    if not match:
        return {"statusCode": 400, "body": "Invalid path"}

    filename = match.group(1)

    # Belt-and-suspenders: check if already generated (race condition guard)
    try:
        existing = s3.get_object(Bucket=BUCKET, Key=f"audio/{filename}.mp3")
        audio_bytes = existing["Body"].read()
    except s3.exceptions.NoSuchKey:
        # Read source text
        text_obj = s3.get_object(Bucket=BUCKET, Key=f"texts/{filename}.txt")
        text = text_obj["Body"].read().decode("utf-8")

        # Synthesize with Polly
        response = polly.synthesize_speech(
            Text=text, OutputFormat="mp3",
            VoiceId="Joanna", Engine="standard"
        )
        audio_bytes = response["AudioStream"].read()

        # Cache to S3 (non-fatal if this fails)
        try:
            s3.put_object(
                Bucket=BUCKET, Key=f"audio/{filename}.mp3",
                Body=audio_bytes, ContentType="audio/mpeg"
            )
        except Exception:
            pass  # Audio still returned even if caching fails

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "audio/mpeg",
            "Cache-Control": "public, max-age=31536000"
        },
        "body": base64.b64encode(audio_bytes).decode("utf-8"),
        "isBase64Encoded": True
    }
Enter fullscreen mode Exit fullscreen mode

Terraform Highlights: Origin Group Configuration

resource "aws_cloudfront_distribution" "cdn" {
  # Primary origin: S3 with OAC
  origin {
    domain_name              = aws_s3_bucket.assets.bucket_regional_domain_name
    origin_id                = "s3-assets"
    origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
  }

  # Failover origin: API Gateway
  origin {
    domain_name = "${aws_api_gateway_rest_api.api.id}.execute-api.us-east-1.amazonaws.com"
    origin_id   = "api-fallback"
    origin_path = "/live"
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  # Origin Group: failover on 403/404
  origin_group {
    origin_id = "s3-with-fallback"
    failover_criteria {
      status_codes = [403, 404]
    }
    member { origin_id = "s3-assets" }
    member { origin_id = "api-fallback" }
  }

  # Route /audio/* through the origin group
  ordered_cache_behavior {
    path_pattern     = "/audio/*"
    target_origin_id = "s3-with-fallback"
    # Uses CachingOptimized managed policy
    cache_policy_id  = "658327ea-f89d-4fab-a63d-7e88639e58f6"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The key is the origin_group block — CloudFront handles all failover logic natively.

Deployment

# Clone the repository
cd serverless-lazy-generation

# Add your text files
echo "Welcome to our platform." > sample-texts/welcome.txt
echo "This is a demo of lazy generation." > sample-texts/demo.txt

# Deploy with Terraform
cd terraform
terraform init
terraform plan
terraform apply

# Note the outputs
# cloudfront_url = "https://d1234abcdef.cloudfront.net"
# sample_audio_url = "https://d1234abcdef.cloudfront.net/audio/welcome.mp3"
Enter fullscreen mode Exit fullscreen mode

Testing

# Get the CloudFront URL from Terraform output
CF_URL=$(terraform -chdir=terraform output -raw cloudfront_url)

# First request — triggers generation (slower, ~2-5 seconds)
time curl -o welcome.mp3 "${CF_URL}/audio/welcome.mp3"

# Second request — served from cache (fast, <100ms)
time curl -o welcome2.mp3 "${CF_URL}/audio/welcome.mp3"

# Verify the files are identical
md5 welcome.mp3 welcome2.mp3

# Check response headers for cache status
curl -I "${CF_URL}/audio/welcome.mp3"
# Look for: X-Cache: Hit from cloudfront

# Request a non-existent text (should return 404)
curl -v "${CF_URL}/audio/nonexistent.mp3"
Enter fullscreen mode Exit fullscreen mode

Extensions: Other Use Cases

The lazy generation pattern isn't limited to audio. Any expensive, deterministic transformation works:

Use Case Source Generator Output
Image thumbnails Original images in S3 Lambda + Sharp Resized images
PDF generation HTML templates + data Lambda + Puppeteer PDF documents
Video transcoding Source video in S3 Lambda + FFmpeg Transcoded video
Open Graph images Article metadata Lambda + Canvas Social preview images
Map tiles Vector data Lambda + Mapbox GL Raster tiles

For each case, the pattern is identical:

  1. S3 stores the output (acts as cache)
  2. CloudFront Origin Group fails over to Lambda on 404
  3. Lambda generates, caches to S3, and returns
  4. All subsequent requests bypass Lambda entirely

Conclusion

The serverless lazy generation pattern gives you the best of both worlds: the simplicity of static file serving with the flexibility of on-demand generation. CloudFront Origin Groups handle all the routing logic at the infrastructure level — no caching libraries, no invalidation jobs, no state management.

The architecture is:

  • Self-healing: Delete a cached file, and it regenerates on next request
  • Cost-efficient: You only pay to generate content that's actually consumed
  • Globally fast: CloudFront edge caching provides sub-50ms responses worldwide
  • Zero-ops: No servers to manage, no batch jobs to monitor, no cache to warm

Whether you're building a content platform, an accessibility layer, or a media processing pipeline, this pattern scales from zero to millions of assets with no code changes.

The complete source code — Lambda function, Terraform infrastructure, and sample texts — is available in the repository. Deploy it in under 5 minutes and start generating audio on demand.

Top comments (0)