All articles
DevOps & CloudSeptember 16, 2026 6 min read

CloudFront Functions vs Lambda@Edge: A Latency and Cost Breakdown from a Real Migration

We moved auth header rewrites and A/B routing off Lambda@Edge and onto CloudFront Functions. Here's what actually got faster, what got harder, and where the math stopped working.

CloudFront Functions vs Lambda@Edge: A Latency and Cost Breakdown from a Real Migration

We had a Lambda@Edge function that had grown, as these things do, into the load-bearing beam of our CDN layer. Header rewrites, geo redirects, an A/B experiment splitter, a signed-URL sanity check. It worked. It also cost us more than we wanted and added a P99 tail we couldn't explain. So we split it — most of it moved to CloudFront Functions, a stubborn minority stayed on Lambda@Edge. Here's what happened.

The setup we started with

One CloudFront distribution, four Lambda@Edge triggers wired to viewer-request. Node.js 18 runtime. Roughly 180M requests per month across two regions of origin. The function did five things:

  1. Normalize Accept-Language into a canonical locale cookie
  2. Redirect based on a small country → path map
  3. Rewrite Authorization headers into a custom X-Session-Token for legacy origins
  4. Route 5% of traffic to a canary origin via header injection
  5. Reject requests with obviously malformed signed URLs before they hit origin

None of it was CPU-heavy. None of it needed network calls. It was string manipulation and a lookup table. Which is exactly the profile CloudFront Functions is designed for — and exactly why we should have questioned Lambda@Edge sooner.

Why it was on Lambda@Edge in the first place

Honesty check: because we already knew Lambda. The team had muscle memory for the tooling, the logs went to CloudWatch in a familiar shape, and we could require an npm package if we wanted to. CloudFront Functions launched in 2021 with a deliberately spartan runtime, and at the time we glanced at the docs and moved on. Three years later we were paying for that glance.

What CloudFront Functions actually is

CloudFront Functions runs a purpose-built JavaScript runtime (roughly ECMAScript 5.1 with some 2015+ additions) inside the CloudFront edge itself, not on Lambda infrastructure. AWS advertises sub-millisecond execution and no cold starts. In our testing that's essentially true, with caveats.

The constraints are real:

  • 10 KB max function size
  • 2 MB max memory
  • 1 ms max CPU time (measured, not wall-clock)
  • No network access, no filesystem, no async/await, no fetch
  • Only viewer-request and viewer-response triggers — no origin-side hooks
  • No environment variables in the traditional sense (you get KeyValueStore, which is separate)

If your edge logic fits inside those walls, CloudFront Functions is dramatically cheaper and faster. If it doesn't, forcing it in there is how you end up with a rewrite that pages you at 3 AM.

The migration, one function at a time

We didn't do a big bang. We split the original Lambda@Edge into distinct concerns and evaluated each against the CloudFront Functions constraints.

What moved cleanly

The locale normalizer, country redirect, and canary router all moved without drama. Here's the shape of the country redirect after porting:

function handler(event) {
  var request = event.request;
  var headers = request.headers;
  var country = headers['cloudfront-viewer-country']
    ? headers['cloudfront-viewer-country'].value
    : 'US';

  var map = {
    'DE': '/de',
    'FR': '/fr',
    'JP': '/jp'
  };

  var prefix = map[country];
  if (prefix && request.uri.indexOf(prefix) !== 0) {
    return {
      statusCode: 302,
      statusDescription: 'Found',
      headers: {
        'location': { value: prefix + request.uri }
      }
    };
  }

  return request;
}

The biggest adjustment was giving up const, arrow functions, and template literals. Not a hardship. The bigger adjustment was giving up npm — the country map used to be a package. Now it's inlined at build time by a small script in CI that reads a YAML file and emits a .js file under the 10 KB ceiling.

What we had to redesign

The Authorization → X-Session-Token rewrite involved decoding a JWT to pull a claim. We were doing that in Lambda@Edge with a small library. In CloudFront Functions we couldn't pull the library, and hand-rolling base64url + JSON parsing inside the 1 ms CPU budget was tight but doable. What killed it was the KeyValueStore lookup for the signing key rotation table — that added latency we didn't want to model, and the KVS eventual consistency window (single-digit seconds in our tests) was a problem for key rotation.

So that piece stayed on Lambda@Edge, but we moved it from viewer-request to origin-request. That change alone cut its invocation count by roughly 40% because CloudFront's cache absorbed the repeats.

What stayed on Lambda@Edge

The signed-URL validator. It needed HMAC-SHA256 with a rotating key, and the timing-safe comparison story inside the CloudFront Functions runtime made us nervous. We could probably have made it work. We chose not to spend the week.

The numbers

All measurements from our production traffic over a 14-day window pre- and post-migration. Take them as directional, not as benchmarks you should quote back at us.

Latency at the edge (function execution time only):

  • Lambda@Edge viewer-request, p50: ~1.2 ms, p99: ~14 ms
  • CloudFront Functions, p50: ~0.15 ms, p99: ~0.9 ms

The p99 gap is the interesting one. Lambda@Edge has real cold starts, especially in less-trafficked edge locations, and we saw p99s spike to 40+ ms during regional traffic shifts. CloudFront Functions was flat. That's the marquee win.

End-user TTFB (from RUM, cache misses excluded):

We saw a median improvement of roughly 8–12 ms globally, with larger gains (20–30 ms) in Asia-Pacific PoPs where our Lambda@Edge cold starts were worst. Not life-changing, but visible in Core Web Vitals for pages that were borderline.

Cost:

Lambda@Edge is billed on invocations plus GB-seconds. Our monthly cost for the four triggers had been climbing past $900. CloudFront Functions is billed per million invocations at a much lower rate. For the three functions we moved, the new run rate landed around $110/month. The Lambda@Edge remainder (JWT rewrite and signed-URL validator, now on origin-request) sits around $180.

Call it a ~68% reduction, or about $600/month, plus the latency win. Reasonable. Not transformative. Worth the two-sprint effort we spent on it because the observability improvements alone (see below) were overdue.

The things nobody warns you about

Logging is worse

CloudFront Functions logs go to CloudWatch, but they're best-effort and formatted differently from Lambda logs. There's no built-in request ID correlation with CloudFront access logs unless you thread it yourself. We ended up injecting a short random ID into a response header and joining logs downstream. Workable, but not what you're used to.

Testing is primitive

The CloudFront Functions test harness in the AWS console is genuinely useful for one-off checks. For CI, we wrote a small Node harness that loads the function source into a VM context with the CloudFront-shaped event object. It catches syntax errors and most logic bugs but doesn't enforce the 1 ms CPU budget — you find out about that at deploy time.

KeyValueStore is not a config store

We tried using KVS as a general config mechanism. It's fine for lookup tables that change hourly or slower. It is not a real-time config system. Updates propagate on the order of seconds and there's no atomic multi-key update. If your logic needs consistent config, bake it into the function and redeploy.

The 10 KB limit is stricter than it looks

It's the size of the deployed function, not the source. Minification helps, but if you're inlining a lookup table it can eat the budget fast. Our locale normalizer is 3.8 KB minified because the language map is chunky. Plan for it.

Where we'd start

If you have Lambda@Edge functions on viewer-request or viewer-response doing pure request/response munging, audit them this quarter. Sort by invocation count, pick the cheapest one to port, and use it as a pilot for your CI and observability setup. The runtime constraints will tell you honestly whether the rest are candidates.

Don't force it. If your function needs a network call, async I/O, or a real crypto library, stay on Lambda@Edge and consider moving the trigger from viewer-request to origin-request so the CloudFront cache does more work for you. That single change is often a bigger cost win than the runtime swap.

If you want a second pair of eyes on your edge topology or CDN spend, our DevOps and cloud team does this kind of audit regularly — we usually find one function that shouldn't exist and one that's on the wrong trigger.

#AWS#CloudFront#Edge Compute#Performance#Cost

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project