+1 (726) 207-9872

Caching for Serverless APIs: Module Scope, CloudFront, API Gateway, DAX, and ElastiCache Serverless with Serverless Framework V4

Most serverless latency and cost problems are really repetition problems: the same DynamoDB query runs ten thousand times an hour, the same pricing table is fetched on every invocation, the same JSON response is rebuilt for every anonymous visitor. Caching removes that repetition — but serverless gives you four or five places to put a cache, each with different latency, cost, and invalidation behaviour.

This post walks through the layers in the order we evaluate them on performance engagements, with Serverless Framework V4 configuration for each, and finishes with the invalidation rules that keep a cache from becoming a correctness bug.

The layers, cheapest first

LayerTypical read latencyCharged forBest for
In-process (module scope)microsecondsnothingconfig, secrets, JWKS, feature flags
CloudFront / edge cache~10–40 ms to the viewerrequests + transferpublic GETs, anonymous content
API Gateway cache (REST only)~5–10 msper hour, per GB of cacheper-account GETs behind an authorizer
DAX (DynamoDB accelerator)~1 msper node-hourread-heavy DynamoDB item/query traffic
ElastiCache Serverless (Valkey/Redis)~1–2 msper GB-hour + ECPUsshared computed results, rate counters, sessions

The order matters. A cache hit you never leave the function for is free; a cache hit at the edge never wakes a function at all. Reach for a managed cache cluster only when the cheaper layers cannot express what you need.

Layer 1: the one everybody forgets — module scope

A warm Lambda execution environment survives between invocations, so anything you assign at module scope persists until that environment is recycled (minutes to hours). That is a real, free, per-environment cache.

// src/config.js
import { SSMClient, GetParametersByPathCommand } from "@aws-sdk/client-ssm";

const ssm = new SSMClient({});
let cache = null;
let loadedAt = 0;
const TTL_MS = 5 * 60 * 1000;

export async function getConfig() {
  const now = Date.now();
  if (cache && now - loadedAt < TTL_MS) return cache;

  const out = await ssm.send(
    new GetParametersByPathCommand({ Path: "/app/prod/", WithDecryption: true })
  );
  cache = Object.fromEntries(out.Parameters.map(p => [p.Name.split("/").pop(), p.Value]));
  loadedAt = now;
  return cache;
}

Three rules for module-scope caches:

  1. Always give it a TTL. Without one, a rotated secret stays broken until the environment is recycled, which can be hours.
  2. Never cache per-user data in it. Execution environments are reused across different callers. A per-user object in a module-level variable is a cross-tenant data leak waiting to happen.
  3. Keep it small. It consumes the function's memory, which you are paying for on every invocation.

For parameters and secrets specifically, the AWS Parameters and Secrets Lambda Extension does this for you with a local HTTP endpoint, and Powertools for AWS Lambda ships a getParameter helper with built-in TTL. Use one of those rather than hand-rolling, unless the shape of your config is unusual.

Layer 2: CloudFront in front of the API

For anonymous, cacheable GETs, the best function invocation is the one that never happens. If you already front your API with CloudFront (see our post on custom domains and CloudFront for V4 APIs), add a cache behaviour with a policy that honours origin headers:

resources:
  Resources:
    ApiDistribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Enabled: true
          Origins:
            - Id: HttpApi
              DomainName: !Sub "${HttpApi}.execute-api.${AWS::Region}.amazonaws.com"
              CustomOriginConfig:
                OriginProtocolPolicy: https-only
          DefaultCacheBehavior:
            TargetOriginId: HttpApi
            ViewerProtocolPolicy: redirect-to-https
            # CachingDisabled — the default for anything authenticated
            CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          CacheBehaviors:
            - PathPattern: /v1/catalog/*
              TargetOriginId: HttpApi
              ViewerProtocolPolicy: redirect-to-https
              # CachingOptimized
              CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6

Then let the function decide the TTL per response:

return {
  statusCode: 200,
  headers: {
    "content-type": "application/json",
    // 60s at the edge, serve stale for 10 min while revalidating
    "cache-control": "public, max-age=0, s-maxage=60, stale-while-revalidate=600",
  },
  body: JSON.stringify(catalog),
};

stale-while-revalidate is the quiet hero here: during a traffic spike or an origin hiccup, viewers keep getting a slightly old response instead of a cold start or a 502.

Never put a caching behaviour on a path that varies by Authorization unless that header is in the cache key — serving one customer's response to another is the classic way this goes wrong. If the response varies per user, use an OriginRequestPolicy/cache key that includes the relevant header, or skip this layer entirely.

Layer 3: API Gateway caching (REST APIs only)

REST (v1) API Gateway has a built-in cache keyed on the method's request parameters. HTTP (v2) APIs — the default for most V4 projects — do not have it, which surprises people mid-migration. If you are on REST:

provider:
  apiGateway:
    # cluster size in GB: 0.5, 1.6, 6.1, 13.5, 28.4, 58.2, 118, 237
    caching:
      enabled: true
      clusterSize: '0.5'
      ttlInSeconds: 60
      dataEncrypted: true

functions:
  getProduct:
    handler: src/product.get
    events:
      - http:
          path: /products/{id}
          method: get
          caching:
            enabled: true
            cacheKeyParameters:
              - name: request.path.id

Two things to budget for: the cache cluster is billed per hour regardless of traffic, and a 0.5 GB cluster is small — it is for hot keys, not your whole catalogue. Also expose a way to bust it (Cache-Control: max-age=0 from a caller with InvalidateCache permission) for support scenarios.

Layer 4: DAX for read-heavy DynamoDB

If the repetition is a DynamoDB GetItem or Query with high read-to-write ratio, DAX is the lowest-friction option: it is API-compatible, so you swap the client and keep your code. Microsecond-to-millisecond reads, and it absorbs hot partitions.

The catch is that DAX is a VPC-only cluster, so your functions must join the VPC — which brings ENI management, NAT costs for outbound internet, and the networking considerations covered in our Lambda-to-Postgres post. For a single hot table it is often simpler to cache the derived result in memory or in ElastiCache instead.

A quick decision rule: DAX if the thing you want cached is a DynamoDB read and the item is read far more than written; something else if what you want cached is a computed result assembled from several sources.

Layer 5: ElastiCache Serverless (Valkey) for shared state

When you need a cache shared across all execution environments — computed aggregates, idempotency records, session data, per-tenant rate counters — ElastiCache Serverless removes the old objection that Redis meant managing nodes. It scales capacity automatically and bills per GB-hour of storage plus ECPUs consumed, with no cluster sizing decision. Valkey (the open-source Redis fork) is the cheaper engine option and is API-compatible with the Redis clients you already use.

provider:
  vpc:
    securityGroupIds: [!Ref LambdaSg]
    subnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
  environment:
    CACHE_ENDPOINT: !GetAtt CacheCluster.Endpoint.Address

resources:
  Resources:
    CacheCluster:
      Type: AWS::ElastiCache::ServerlessCache
      Properties:
        ServerlessCacheName: ${self:service}-${sls:stage}
        Engine: valkey
        SecurityGroupIds: [!Ref CacheSg]
        SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
        CacheUsageLimits:
          DataStorage: { Maximum: 2, Unit: GB }
          ECPUPerSecond: { Maximum: 5000 }

Client code, with the connection created once at module scope and TLS on:

import { createClient } from "redis";

const client = createClient({
  url: `rediss://${process.env.CACHE_ENDPOINT}:6379`,
  socket: { reconnectStrategy: (r) => Math.min(r * 50, 500) },
});
let connecting = null;

async function cache() {
  if (!client.isOpen) {
    connecting ??= client.connect();
    await connecting;
  }
  return client;
}

export async function getTenantSummary(tenantId) {
  const c = await cache();
  const key = `summary:v2:${tenantId}`;

  const hit = await c.get(key);
  if (hit) return JSON.parse(hit);

  const fresh = await buildSummary(tenantId);     // the expensive part
  await c.set(key, JSON.stringify(fresh), { EX: 300 });
  return fresh;
}

Operational notes that matter in a Lambda context:

  • Always set a timeout on cache calls (1–2 seconds) and fall through to the origin on failure. A cache that is down must degrade performance, not availability.
  • Version your keys (summary:v2:) so a schema change is a deploy, not a flush.
  • Guard against stampedes. When a hot key expires under load, hundreds of concurrent invocations recompute it at once. Either set a short lock key (SET key NX EX 10) so one invocation refreshes while others serve stale, or refresh proactively from a scheduled job.
  • ElastiCache Serverless lives in a VPC, so the same subnet/NAT considerations as DAX apply.

Invalidation: the part that causes incidents

Pick one of three strategies per cached item and write it down:

  1. TTL only. Simplest, and correct whenever "up to N seconds stale" is acceptable. Default to this.
  2. Write-through / delete-on-write. The code path that updates the record also deletes or rewrites the cache key. Correct, but every write path must remember — easy to miss in a second service.
  3. Event-driven invalidation. A DynamoDB Stream or EventBridge rule fires a small invalidator function that deletes affected keys. This is the version that survives multiple writers.
functions:
  invalidate:
    handler: src/invalidate.handler
    events:
      - stream:
          type: dynamodb
          arn: !GetAtt ProductsTable.StreamArn
          batchSize: 100
          filterPatterns:
            - eventName: [MODIFY, REMOVE]

Whichever you choose, make staleness observable: emit the cached object's age as a CloudWatch metric, and alarm when p99 age drifts past your intended TTL. That is usually the first sign that an invalidation path has silently broken.

Measuring whether it worked

Cache work is only finished when you can show the numbers. Emit a hit/miss metric per cache (Powertools' EMF helper makes this one line), then compare three things before and after:

  • p95 endpoint latency — the user-visible payoff.
  • Origin invocation count and DynamoDB consumed RCUs — where the savings actually appear on the bill.
  • Cache cost — ECPU/GB-hours, API Gateway cluster hours, or DAX node-hours.

A caching layer that costs more than the reads it avoids is a surprisingly common outcome at low traffic. At high traffic, a 90% hit rate on a hot query routinely pays for itself several times over.

Where to start

If you are adding caching to an existing serverless app, do it in this order: TTL'd module-scope caching of config and secrets (an afternoon, zero cost), Cache-Control headers plus a CloudFront behaviour on genuinely public GETs (a day, large win), and only then a shared cache for computed results that several functions need. Measure after each step; stop when the latency is good enough.

Our Serverless Performance Tuning and Serverless Architecture Design engagements include this review, with the hit-rate and cost numbers to back each layer. Get in touch if you would like us to run it against your stack.