Edge Computing with AWS: CloudFront Functions vs Lambda@Edge
A technical guide to choosing and implementing AWS edge computing for global apps, with practical examples and cost optimization strategies.
Edge computing moves code execution from centralized data centers to locations near users. AWS CloudFront runs a global edge network and offers two ways to put code on it: CloudFront Functions and Lambda@Edge. Default to CloudFront Functions. They cover header work, redirects, and cache key normalization at a fraction of the price, and Lambda@Edge earns its cost only when a request needs network access, a request body, or origin-side events.
Where Edge Code Runs#
Both services cut latency by handling a request at the edge instead of at the origin, but they sit at different points in the request lifecycle and carry different limits.
CloudFront Functions run a restricted JavaScript runtime with no network access, sized for high-volume transformations such as cache key normalization and header manipulation. Lambda@Edge runs a full Node.js or Python process, which is what makes external API calls, request body inspection, and origin-side logic possible.
Execution Points#
CloudFront provides four execution points where edge functions can run:
- Viewer Request: After CloudFront receives request, before checking cache
- Viewer Response: Before returning response to viewer
- Origin Request: Before forwarding to origin (cache miss only) - Lambda@Edge only
- Origin Response: After receiving response from origin - Lambda@Edge only
Service Comparison#
The rows below are the constraints that decide which service a given task belongs to.
Feature Comparison#
| Feature | CloudFront Functions | Lambda@Edge |
|---|---|---|
| Execution Location | CloudFront edge locations | CloudFront edge locations |
| Runtime | JavaScript only | Node.js 22.x, Python 3.13 |
| Execution Time | < 1 millisecond | 5s (viewer), 30s (origin) |
| Memory | 2 MB | 128 MB - 10 GB |
| Max Package Size | 10 KB | 1 MB (viewer), 50 MB (origin) |
| Network Access | No | Yes |
| Event Types | Viewer request/response | All 4 event types |
| Request Body Access | No | Yes (origin events) |
| Response Size | 40 KB | 1 MB |
| Pricing (per 1M) | $0.10 invocations | $0.60 invocations + compute |
| Cold Start | None | Yes |
| KeyValueStore | Yes | No |
Cost Impact#
For 10 billion monthly requests, the cost difference is significant:
- CloudFront Functions: 10,000M × $0.10 = $1,000
- Lambda@Edge: 10,000M × $0.60 + compute = $6,000-$8,000
CloudFront Functions are 5-8x cheaper for simple use cases.
Decision Framework#
CloudFront Functions in Production#
CloudFront Functions execute at CloudFront edge locations with sub-millisecond latency. They’re ideal for high-volume, simple operations that don’t require network access.
Cache Keys and Security Headers#
Optimizing cache hit ratio by normalizing query parameters can significantly reduce origin load.
// CloudFront Function for cache key normalization
function handler(event) {
var request = event.request;
var querystring = request.querystring;
// Normalize device indicators to standard format
if (querystring.device) {
var deviceValue = querystring.device.value.toLowerCase();
if (deviceValue === 'm' || deviceValue === 'mobile') {
querystring.device.value = 'mobile';
} else if (deviceValue === 'd' || deviceValue === 'desktop') {
querystring.device.value = 'desktop';
}
}
// Sort query parameters alphabetically for consistent cache keys
var sortedQuerystring = {};
Object.keys(querystring)
.sort()
.forEach(function(key) {
sortedQuerystring[key] = querystring[key];
});
request.querystring = sortedQuerystring;
// Normalize Accept-Encoding header
if (request.headers['accept-encoding']) {
var acceptEncoding = request.headers['accept-encoding'].value;
if (acceptEncoding.includes('br')) {
request.headers['accept-encoding'].value = 'br,gzip';
} else if (acceptEncoding.includes('gzip')) {
request.headers['accept-encoding'].value = 'gzip';
}
}
return request;
}
Without normalization, ?device=m&size=l and ?size=l&device=mobile are two cache entries for one object. Collapsing those variants raises the hit ratio and keeps the corresponding requests away from the origin.
Security headers follow the same pattern at the viewer response stage:
// CloudFront Function for security headers (viewer response)
function handler(event) {
var response = event.response;
var headers = response.headers;
// Strict-Transport-Security (HSTS)
headers['strict-transport-security'] = {
value: 'max-age=31536000; includeSubDomains; preload'
};
// Content-Security-Policy (CSP)
headers['content-security-policy'] = {
value: "default-src 'self'; img-src 'self' https: data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
};
// X-Content-Type-Options
headers['x-content-type-options'] = {
value: 'nosniff'
};
// X-Frame-Options
headers['x-frame-options'] = {
value: 'DENY'
};
// X-XSS-Protection
headers['x-xss-protection'] = {
value: '1; mode=block'
};
// Referrer-Policy
headers['referrer-policy'] = {
value: 'strict-origin-when-cross-origin'
};
// Permissions-Policy
headers['permissions-policy'] = {
value: 'geolocation=(), microphone=(), camera=()'
};
return response;
}
For 5 billion requests per month, this costs $500 with CloudFront Functions versus $3,000+ with Lambda@Edge.
A/B Testing with KeyValueStore#
CloudFront Functions support KeyValueStore for dynamic configuration without redeployment.
// CloudFront Function with KeyValueStore for A/B testing
import cf from 'cloudfront';
const kvsId = 'a1b2c3d4-5678-90ab-cdef-example12345';
const kvsHandle = cf.kvs(kvsId);
async function handler(event) {
var request = event.request;
var uri = request.uri;
// Check if user already has experiment assignment
var cookies = request.cookies;
var experimentCookie = cookies['experiment_variant'];
var variant;
if (experimentCookie) {
variant = experimentCookie.value;
} else {
// Get experiment configuration from KeyValueStore
var experimentConfig = await kvsHandle.get('experiment_homepage');
var config = JSON.parse(experimentConfig);
// Assign variant based on traffic split
var random = Math.random() * 100;
if (random < config.variantA_percentage) {
variant = 'A';
} else if (random < (config.variantA_percentage + config.variantB_percentage)) {
variant = 'B';
} else {
variant = 'C';
}
// Set cookie for future requests
request.cookies['experiment_variant'] = { value: variant };
}
// Rewrite URL based on variant
if (uri === '/') {
request.uri = `/variants/home-${variant.toLowerCase()}.html`;
}
return request;
}
Tip
KeyValueStore updates propagate within minutes. Use versioning during development and implement gradual rollout for production changes.
Lambda@Edge for Origin-Side Logic#
Lambda@Edge handles operations that need network access, external APIs, or origin-side logic. The trade-off is higher cost and a possible cold start on the critical path.
Geo-Targeting and Localization#
CloudFront provides geographic headers that Lambda@Edge can use for intelligent routing.
// Lambda@Edge function for geo-targeting (viewer request)
'use strict';
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// CloudFront provides geo headers
const country = headers['cloudfront-viewer-country']
? headers['cloudfront-viewer-country'][0].value
: 'US';
// Map countries to language preferences
const countryToLocale = {
'DE': '/de',
'AT': '/de',
'CH': '/de',
'TR': '/tr',
'FR': '/fr',
'ES': '/es',
'IT': '/it',
'US': '/en',
'GB': '/en',
'CA': '/en'
};
// Only redirect root path
if (request.uri === '/') {
const locale = countryToLocale[country] || '/en';
// Check if user has locale preference cookie
const cookies = headers.cookie || [];
let localePreference = null;
for (let cookie of cookies) {
const matches = cookie.value.match(/locale=([^;]+)/);
if (matches) {
localePreference = matches[1];
break;
}
}
// Redirect to localized path
const targetUri = localePreference || locale;
const response = {
status: '302',
statusDescription: 'Found',
headers: {
'location': [{
key: 'Location',
value: targetUri
}],
'cache-control': [{
key: 'Cache-Control',
value: 'max-age=3600'
}]
}
};
callback(null, response);
} else {
callback(null, request);
}
};
JWT Authentication#
Validating JWT tokens at the edge prevents unauthorized requests from reaching the origin.
// Lambda@Edge function for JWT validation (viewer request)
'use strict';
const jwt = require('jsonwebtoken');
// In production, fetch from AWS Secrets Manager
const JWT_SECRET = process.env.JWT_SECRET;
exports.handler = async (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// Protected paths
const protectedPaths = ['/api/', '/dashboard/', '/admin/'];
const isProtected = protectedPaths.some(path => request.uri.startsWith(path));
if (!isProtected) {
callback(null, request);
return;
}
// Extract Authorization header
const authHeader = headers.authorization || headers.Authorization;
if (!authHeader || authHeader.length === 0) {
callback(null, unauthorizedResponse('Missing authorization header'));
return;
}
const token = authHeader[0].value.replace('Bearer ', '');
try {
// Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'],
maxAge: '24h'
});
// Add user info to custom headers for origin
request.headers['x-user-id'] = [{
key: 'X-User-Id',
value: decoded.userId
}];
request.headers['x-user-email'] = [{
key: 'X-User-Email',
value: decoded.email
}];
callback(null, request);
} catch (error) {
console.error('JWT validation failed:', error.message);
callback(null, unauthorizedResponse('Invalid or expired token'));
}
};
function unauthorizedResponse(message) {
return {
status: '401',
statusDescription: 'Unauthorized',
headers: {
'www-authenticate': [{
key: 'WWW-Authenticate',
value: 'Bearer realm="Access to protected resources"'
}],
'content-type': [{
key: 'Content-Type',
value: 'application/json'
}]
},
body: JSON.stringify({
error: message
})
};
}
Tip
Never hardcode secrets in Lambda@Edge functions. Use AWS Secrets Manager with caching to minimize API calls and cold start impact.
Origin Selection and Failover#
Dynamic origin routing with health checks enables resilient architectures.
// Lambda@Edge function for origin selection (origin request)
'use strict';
const https = require('https');
exports.handler = async (event, context, callback) => {
const request = event.Records[0].cf.request;
// Primary and secondary origins
const origins = {
primary: {
domainName: 'api-primary.example.com',
port: 443,
protocol: 'https',
path: '/v1'
},
secondary: {
domainName: 'api-secondary.example.com',
port: 443,
protocol: 'https',
path: '/v1'
}
};
let selectedOrigin = origins.primary;
// Route based on custom header
const routingHeader = request.headers['x-origin-override'];
if (routingHeader && routingHeader[0].value === 'secondary') {
selectedOrigin = origins.secondary;
}
// Route based on path
if (request.uri.startsWith('/legacy/')) {
selectedOrigin = origins.secondary;
}
// Health check primary origin
try {
const isHealthy = await checkOriginHealth(selectedOrigin.domainName);
if (!isHealthy) {
console.log(`Primary origin ${selectedOrigin.domainName} unhealthy, failing over`);
selectedOrigin = origins.secondary;
}
} catch (error) {
console.error('Health check failed:', error);
selectedOrigin = origins.secondary;
}
// Update request with selected origin
request.origin = {
custom: {
domainName: selectedOrigin.domainName,
port: selectedOrigin.port,
protocol: selectedOrigin.protocol,
path: selectedOrigin.path,
sslProtocols: ['TLSv1.2'],
readTimeout: 30,
keepaliveTimeout: 5,
customHeaders: {}
}
};
callback(null, request);
};
function checkOriginHealth(domainName) {
return new Promise((resolve) => {
const options = {
hostname: domainName,
port: 443,
path: '/health',
method: 'GET',
timeout: 2000
};
const req = https.request(options, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
Performance Optimization#
Reducing Lambda@Edge Cold Starts#
Cold starts affect user experience directly when functions execute in the viewer request phase. Package size is the most direct lever: initialize dependencies outside the handler so warm invocations skip the setup cost entirely.
// BAD: Initialize inside handler
exports.handler = async (event) => {
const AWS = require('aws-sdk'); // Slow!
const dynamodb = new AWS.DynamoDB.DocumentClient();
// ...
};
// GOOD: Initialize outside handler
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
// Reuses initialization on warm invocations
};
Memory allocation matters too. More memory equals more CPU, which reduces cold start time. For Lambda@Edge functions with moderate dependencies, 512 MB is often the sweet spot.
Dependencies compound the effect. Replace heavy libraries with lighter alternatives:
moment.js→date-fnsor nativeDate- Full AWS SDK → individual service clients
- Large image processing libraries → minimal implementations
Cold start grows with everything the function has to load before it can answer. A dependency-free function is barely noticeable, bundling the AWS SDK adds a measurable delay, and native modules such as Sharp are the worst case. Measure your own functions in CloudWatch instead of assuming a range.
CloudFront Functions Optimization#
Keep Code Minimal: The 10 KB limit includes all code. Use KeyValueStore for configuration data instead of hardcoding values.
Leverage KeyValueStore: Offload configuration data to avoid function redeployment. KeyValueStore provides 5 MB storage with sub-millisecond reads.
Cost Analysis and Optimization#
Detailed Cost Calculation#
Scenario: 5 billion requests/month, 50ms average duration, 128 MB memory
CloudFront Functions:
5,000M invocations × $0.10 = $500
Total: $500/month
Lambda@Edge:
Request charges: 5,000M × $0.60 = $3,000
Compute: 5,000M × 0.05s × 0.125GB × $0.00005001 = $1,563
Total: $4,563/month
Savings: $4,063/month (89% reduction) using CloudFront Functions
Cost Optimization Strategies#
Optimize Lambda@Edge memory: right-size memory allocation using CloudWatch metrics. More memory can reduce execution time, offsetting higher memory costs.
Reduce invocation frequency: use CloudFront cache policies effectively to minimize edge function invocations. Every cached response avoids a function invocation.
Monitor actual usage: set up CloudWatch alarms for cost thresholds:
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
const lambdaEdgeCostAlarm = new cloudwatch.Alarm(this, 'LambdaEdgeCostAlarm', {
metric: new cloudwatch.Metric({
namespace: 'AWS/Lambda',
metricName: 'Invocations',
dimensionsMap: {
FunctionName: edgeFunction.functionName,
},
statistic: 'Sum',
period: cdk.Duration.days(1),
}),
threshold: 1_000_000_000, // 1 billion invocations/day
evaluationPeriods: 1,
alarmDescription: 'Lambda@Edge invocations exceeding budget threshold',
});
Debugging and Logging Challenges#
Lambda@Edge logs appear in the AWS region where the function executes, making debugging complex.
Finding Logs Across Regions#
Check CloudFront Response Headers:
curl -I https://your-distribution.cloudfront.net/path
# Look for: x-amz-cf-pop: IAD89-P1
# IAD = us-east-1 region
Airport Code to Region Mapping:
- IAD (Dulles) → us-east-1
- SFO (San Francisco) → us-west-1
- DUB (Dublin) → eu-west-1
- NRT (Tokyo) → ap-northeast-1
- SYD (Sydney) → ap-southeast-2
Structured Logging Best Practice#
// Lambda@Edge function with structured logging
'use strict';
exports.handler = async (event, context) => {
const request = event.Records[0].cf.request;
const requestId = context.requestId;
// Structured log for easy parsing
const logContext = {
requestId,
uri: request.uri,
method: request.method,
country: request.headers['cloudfront-viewer-country']?.[0]?.value,
timestamp: new Date().toISOString(),
};
console.log('REQUEST_START', JSON.stringify(logContext));
try {
// Your logic here
console.log('PROCESSING', JSON.stringify({ ...logContext, step: 'validation' }));
return request;
} catch (error) {
console.error('ERROR', JSON.stringify({
...logContext,
error: error.message,
stack: error.stack,
}));
throw error;
} finally {
console.log('REQUEST_END', JSON.stringify(logContext));
}
};
Use CloudWatch Logs Insights to query structured logs:
fields @timestamp, @message
| filter @message like /ERROR/
| sort @timestamp desc
| limit 100
Fixing Edge Function Failures#
Response Size Overruns (502 Errors)#
A function response over 1 MB triggers a CloudFront 502. Check the size before returning it:
const responseBody = JSON.stringify(data);
const sizeInBytes = Buffer.byteLength(responseBody, 'utf8');
if (sizeInBytes > 1048576) { // 1 MB = 1048576 bytes
console.error(`Response size ${sizeInBytes} exceeds 1MB limit`);
// Return reference to S3 object instead
return {
status: '200',
body: JSON.stringify({
url: `https://s3.amazonaws.com/bucket/response-${requestId}.json`
})
};
}
Viewer Request Timeouts#
Problem: Viewer request Lambda@Edge function times out.
Promise.race enforces the timeout:
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 4000)
);
const result = await Promise.race([
fetchExternalData(),
timeoutPromise
]);
Cache Key Inefficiency#
Cache hit ratio is low and origin requests are high. Normalize query parameters before CloudFront computes the cache key:
function normalizeQueryString(querystring) {
// Remove tracking parameters
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid'];
trackingParams.forEach(param => delete querystring[param]);
// Sort remaining parameters
const sorted = {};
Object.keys(querystring).sort().forEach(key => {
sorted[key] = querystring[key];
});
return sorted;
}
AWS CDK Deployment Pattern#
Here’s a complete CDK stack for deploying CloudFront with edge functions:
// AWS CDK stack for CloudFront with edge functions
import * as cdk from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import * as path from 'path';
export class EdgeComputingStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
// IMPORTANT: Lambda@Edge must be deployed in us-east-1
super(scope, id, { ...props, env: { region: 'us-east-1' } });
// S3 bucket for origin
const bucket = new s3.Bucket(this, 'OriginBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
// CloudFront Function for cache key normalization
const cacheKeyFunction = new cloudfront.Function(this, 'CacheKeyNormalization', {
code: cloudfront.FunctionCode.fromFile({
filePath: path.join(__dirname, '../functions/cache-key-normalization.js'),
}),
runtime: cloudfront.FunctionRuntime.JS_2_0,
comment: 'Normalize cache keys for better hit ratio',
});
// CloudFront Function for security headers
const securityHeadersFunction = new cloudfront.Function(this, 'SecurityHeaders', {
code: cloudfront.FunctionCode.fromFile({
filePath: path.join(__dirname, '../functions/security-headers.js'),
}),
runtime: cloudfront.FunctionRuntime.JS_2_0,
comment: 'Add security headers to all responses',
});
// Lambda@Edge function for JWT authentication
const jwtAuthFunction = new cloudfront.experimental.EdgeFunction(
this,
'JwtAuthFunction',
{
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/jwt-auth')),
timeout: cdk.Duration.seconds(5),
memorySize: 128,
}
);
// Cache policy for optimized caching
const cachePolicy = new cloudfront.CachePolicy(this, 'OptimizedCachePolicy', {
cachePolicyName: 'EdgeComputingOptimized',
comment: 'Optimized cache policy with normalized keys',
defaultTtl: cdk.Duration.hours(24),
maxTtl: cdk.Duration.days(365),
minTtl: cdk.Duration.seconds(1),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
'CloudFront-Viewer-Country',
'CloudFront-Viewer-Country-Region'
),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.allowList(
'w', 'h', 'q', 'format'
),
});
// CloudFront distribution
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: new origins.S3Origin(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy,
functionAssociations: [
{
function: cacheKeyFunction,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
{
function: securityHeadersFunction,
eventType: cloudfront.FunctionEventType.VIEWER_RESPONSE,
},
],
edgeLambdas: [
{
functionVersion: jwtAuthFunction.currentVersion,
eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
},
],
},
enableLogging: true,
logIncludesCookies: true,
});
// Outputs
new cdk.CfnOutput(this, 'DistributionDomainName', {
value: distribution.distributionDomainName,
});
new cdk.CfnOutput(this, 'DistributionId', {
value: distribution.distributionId,
});
}
}
Warning
Lambda@Edge functions must always be created in us-east-1 region, regardless of where your CloudFront distribution is deployed.
When to Reach for Lambda@Edge#
Two hard limits decide most moves between the two services: the 10 KB code cap on CloudFront Functions and the 1 MB response cap on Lambda@Edge.
A workable order is to start with a CloudFront managed cache policy, add a CloudFront Function once the cache key needs shaping, and introduce Lambda@Edge only when one of those limits forces it. Whichever service ends up in the path, it sits on every request, so handle errors by returning the original request unchanged and keep an eye on the error rate.
References#
- Customize at the Edge with CloudFront Functions (opens in new tab) - Official guide for lightweight viewer-request and viewer-response functions
- Customize at the Edge with Lambda@Edge (opens in new tab) - Full Node.js/Python execution at CloudFront edge locations
- Differences Between CloudFront Functions and Lambda@Edge (opens in new tab) - Feature comparison, runtime limits, and cost trade-offs
- Lambda@Edge Event Structure (opens in new tab) - Request and response event objects for all four CloudFront trigger points
- Restrictions on Lambda@Edge (opens in new tab) - Hard limits including 1 MB response size and us-east-1 deployment requirement
- Lambda@Edge Example Functions (opens in new tab) - Reference implementations for authentication, redirects, and header manipulation
- Edge Function Logs - Amazon CloudFront (opens in new tab) - CloudWatch Logs configuration for distributed edge function logging
Related posts
Practical approaches to managing Lambda Layer versions across dev, staging, and production with AWS CDK, automated deployment pipelines, and rollbacks.
aws · lambda · aws-cdk +4
Learn to build automated preview environments using AWS CDK, Lambda, and GitHub Actions for seamless PR testing and review workflows
aws-cdk · serverless · ci-cd +5
Multi-environment deployment, performance optimization at scale, cost management, and monitoring with solid incident response patterns.
aws-cdk · lambda · dynamodb +6
Tune AWS Lambda performance: the memory-to-CPU model, benchmarking with Power Tuning, cost analysis, and adaptive allocation patterns.
lambda · serverless · performance +2
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
aws · aws-cdk · lambda +4