Every Serverless Framework project eventually hits a request that should never have reached Lambda. A redirect for a legacy URL. A bot with no auth header. A request for /api/v1/* that belongs to an old service. Each one costs you an API Gateway request, a Lambda invocation, a cold start risk, and 80 ms of round trip to your region.
Edge compute fixes that class of problem by running a small amount of code in the CloudFront point of presence nearest the user. AWS gives you two very different tools for it — CloudFront Functions and Lambda@Edge — and picking the wrong one is the usual reason an edge project stalls. This post covers what each one can do, how to deploy both from a Serverless Framework V4 service, and the operational traps (deploy times, logging, versioning) that surprise teams the first time.
The two runtimes, honestly compared
| CloudFront Functions | Lambda@Edge | |
|---|---|---|
| Runs at | 400+ edge locations | 13 regional edge caches |
| Triggers | viewer request, viewer response | viewer request/response, origin request/response |
| Runtime | JavaScript (cloudfront-js-2.0), ECMAScript 5.1+ subset | Node.js and Python, standard Lambda runtimes |
| Max duration | sub-millisecond (1 ms CPU budget) | 5 s (viewer), 30 s (origin) |
| Max size | 10 KB | 1 MB (viewer), 50 MB (origin) |
| Network / filesystem | None | Yes (can call AWS APIs, origins, databases) |
| Body access | No | Yes (with body inclusion enabled) |
| Env vars, VPC, layers | No | No env vars, no VPC |
| Where deployed | global, managed by CloudFront | must live in us-east-1, replicated |
| Price | roughly 1/6th of Lambda@Edge per request | per-request + per-GB-second, no free tier |
The rule we give clients: if the logic only reads and rewrites headers, cookies, URLs, or issues a redirect, it is a CloudFront Function. The moment you need a network call, a secret, an SDK, or a request body, it is Lambda@Edge — and you should first ask whether it belongs at the edge at all rather than in your regional Lambda.
Prerequisites
This post assumes you already have a CloudFront distribution in front of your API or static site. If you do not, our walkthrough on custom domains, TLS, and CloudFront for Serverless Framework V4 APIs builds one. We will extend that distribution here.
CloudFront Functions from serverless.yml
The Serverless Framework has no first-class cloudFrontFunction event, so you declare the function and the distribution association as CloudFormation resources. The code is inlined, which is fine at a 10 KB limit.
service: edge-logic
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
region: eu-west-1 # your app region; CF Functions are global
resources:
Resources:
ViewerRequestFn:
Type: AWS::CloudFront::Function
Properties:
Name: ${self:service}-${sls:stage}-viewer-request
AutoPublish: true
FunctionConfig:
Comment: Normalise paths, strip tracking params, redirect legacy URLs
Runtime: cloudfront-js-2.0
FunctionCode: |
function handler(event) {
var req = event.request;
var uri = req.uri;
// 1. Legacy path redirect - never touches the origin
if (uri.indexOf('/api/v1/') === 0) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: { location: { value: uri.replace('/api/v1/', '/api/v2/') } }
};
}
// 2. Improve cache hit ratio: drop marketing query params
var qs = req.querystring;
['utm_source','utm_medium','utm_campaign','gclid','fbclid']
.forEach(function (k) { delete qs[k]; });
// 3. Cheap country hint for the origin
var country = req.headers['cloudfront-viewer-country'];
req.headers['x-geo'] = { value: country ? country.value : 'ZZ' };
return req;
}
Associate it with a cache behaviour on the distribution:
Cdn:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultCacheBehavior:
TargetOriginId: api
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt ViewerRequestFn.FunctionMetadata.FunctionARN
Origins:
- Id: api
DomainName: ${param:apiDomain}
CustomOriginConfig:
OriginProtocolPolicy: https-only
Three things about the runtime that trip people up:
cloudfront-js-2.0is not Node. Norequire, nofetch, no timers, noasync. You do get ES 2018-ish syntax,console.log, and helper modules for crypto (cryptoHMAC/hash), query-string handling, andBuffer-like conversions.- The 1 ms CPU budget is real. Loops over large arrays or heavy regex will throw a compute-utilization error, which CloudFront surfaces as a 503. Test with the console's test tab, which reports compute utilisation as a percentage — keep it under 50%.
AutoPublish: trueis required for the function to serve traffic; without it the function stays inDEVELOPMENTstage only.
What CloudFront Functions are genuinely good at
- Legacy URL redirects and trailing-slash / index-document normalisation for SPA hosting.
- Stripping or normalising query strings and cookies before the cache key is computed — often the single biggest cache-hit-ratio win available.
- Rejecting obviously bad requests (missing header, wrong method, oversized path) with a 400 before they cost you an invocation.
- Adding security headers on viewer response: HSTS,
X-Content-Type-Options, CSP. - Simple token presence checks and signed-URL/HMAC validation using the built-in crypto helpers — the shared secret can live in a CloudFront KeyValueStore rather than in the code.
CloudFront KeyValueStore for data that changes
Inlining a redirect map into a 10 KB function is fine for ten rules and terrible for a thousand. KeyValueStore gives a function read-only access to a key-value store you can update independently of deploys (no distribution deployment, updates propagate in seconds):
RedirectStore:
Type: AWS::CloudFront::KeyValueStore
Properties:
Name: ${self:service}-${sls:stage}-redirects
import cf from 'cloudfront';
const kvs = cf.kvs();
async function handler(event) {
const req = event.request;
try {
const target = await kvs.get(req.uri);
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: target } } };
} catch (e) {
return req; // key not found: pass through
}
}
Bind the store to the function with FunctionConfig.KeyValueStoreAssociations and note that KVS reads are only available on cloudfront-js-2.0.
Lambda@Edge when you actually need compute
Lambda@Edge earns its cost in two places: origin-request logic (rewriting which origin serves a request, doing A/B origin splits, generating an image variant on a cache miss) and auth that requires verification against a key set.
Deploying it from Serverless Framework V4 has one hard constraint: the function must be created in us-east-1, and the execution role must be assumable by both lambda.amazonaws.com and edgelambda.amazonaws.com. If your application stack lives elsewhere, use a separate small service pinned to us-east-1 — this is a natural fit for Serverless Framework Compose, with the edge service exporting its version ARN and the CDN service consuming it.
# services/edge/serverless.yml
service: edge-auth
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
region: us-east-1 # non-negotiable for Lambda@Edge
memorySize: 128
timeout: 5 # viewer events: hard 5 s ceiling
iam:
role:
statements:
- Effect: Allow
Action: [ 'secretsmanager:GetSecretValue' ]
Resource: !Sub 'arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:edge/*'
functions:
viewerAuth:
handler: src/viewer-auth.handler
resources:
Resources:
ViewerAuthLambdaFunction: # patch the generated role's trust policy
Type: AWS::Lambda::Function
IamRoleLambdaExecution:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: [ lambda.amazonaws.com, edgelambda.amazonaws.com ]
Action: sts:AssumeRole
Outputs:
ViewerAuthVersionArn:
Value: !Ref ViewerAuthLambdaVersion # versioned ARN, required by CloudFront
Export:
Name: ${self:service}-${sls:stage}-viewer-auth-arn
The handler must be plain and dependency-light:
// src/viewer-auth.js — no env vars available at the edge
const ALLOWED = new Set(['/health', '/public']);
export const handler = async (event) => {
const req = event.Records[0].cf.request;
if (ALLOWED.has(req.uri)) return req;
const auth = req.headers.authorization?.[0]?.value;
if (!auth?.startsWith('Bearer ')) {
return {
status: '401',
statusDescription: 'Unauthorized',
headers: { 'www-authenticate': [{ key: 'WWW-Authenticate', value: 'Bearer' }] }
};
}
// Verify against a JWKS cached at module scope; see notes below.
return req;
};
Then associate the versioned ARN on the distribution:
LambdaFunctionAssociations:
- EventType: viewer-request
IncludeBody: false
LambdaFunctionARN: !ImportValue edge-auth-${sls:stage}-viewer-auth-arn
Constraints you have to design around
- No environment variables. Configuration must be baked into the bundle at build time, fetched from Secrets Manager / SSM at cold start and cached at module scope, or read from the request itself (the host header is the usual discriminator between stages).
- No VPC access, so nothing that only exists on a private subnet.
- Logs land in the region nearest the viewer, not your home region. Expect
/aws/lambda/us-east-1.edge-auth-prod-viewerAuthlog groups scattered across a dozen regions. Ship them centrally or you will debug blind; our observability guide covers the subscription-filter pattern. - Deletion is delayed. Replicas are removed asynchronously after you disassociate the function, and CloudFormation will refuse to delete the function for a few hours. Plan stack teardown accordingly.
- Every change is a full CloudFront deployment (typically 3–8 minutes), versus seconds for a CloudFront Function update and near-instant for a KeyValueStore update.
Before you reach for Lambda@Edge
Check whether a managed feature already does the job, because all of these are cheaper and faster:
- CloudFront Origin Access Control for private S3 origins.
- Managed cache and origin-request policies for header/cookie/query forwarding.
- CloudFront Functions + KeyValueStore for dynamic-but-simple routing.
- AWS WAF for rate limiting, bot control, and geo blocking — do not write those in a function.
- Response headers policies for CORS and security headers, which removes most viewer-response functions entirely.
Testing and rollout
- Unit test the handlers locally. Both event shapes are plain JSON; keep fixtures for viewer-request, origin-request, and viewer-response in your repo and assert on the returned object. CloudFront Function code is ES5-ish, so run it under
node --input-type=modulewith a thin shim, or use the CloudFront console test tab, which also reports compute utilisation. - Deploy to a staging distribution first. Edge bugs fail closed for everyone, everywhere, within minutes. There is no per-stage traffic split inside a distribution.
- Use a continuous-deployment policy for a real canary. CloudFront supports staging distributions with weighted or header-based traffic shifting — the edge equivalent of the canary deploys we use for Lambda aliases.
- Alarm on 5xx from CloudFront, specifically
FunctionExecutionErrors,FunctionThrottles, andLambdaExecutionError, and keep the rollback (remove the association, redeploy) documented as a one-command action.
What we usually see it worth
On a recent engagement the combination was undramatic and effective: a CloudFront Function stripping five tracking parameters lifted cache hit ratio from 61% to 88%, which removed roughly 40% of origin Lambda invocations; a second function handled 30,000 legacy redirects a day for about a dollar a month; and exactly one Lambda@Edge function survived review, doing origin selection for a gradual regional migration. Everything else the team had prototyped at the edge moved back into the regional API where it was easier to test.
That is the right ratio. The edge is for decisions that are cheap, stateless, and better made before the request travels — not a second application tier.
Need help deciding what belongs at the edge, or untangling a Lambda@Edge deployment that will not delete? That is day-to-day work for our serverless architecture design and performance tuning teams. Get in touch.