2025-12-25
Edge Computing with AWS: CloudFront Functions vs Lambda@Edge
A comprehensive technical guide to choosing and implementing AWS edge computing solutions for global applications with practical examples and cost optimization strategies.
Edge computing moves code execution from centralized data centers to locations near users. AWS CloudFront operates 1600+ edge locations globally, offering two distinct edge computing solutions: CloudFront Functions and Lambda@Edge. Working with both services taught me that choosing the right one significantly impacts costs, performance, and implementation complexity.
Here’s what I learned building edge computing solutions with AWS.
Understanding CloudFront Edge Computing
Edge computing with CloudFront enables executing code closer to users by running functions at edge locations worldwide. Both services solve latency problems by processing requests at the edge, but they target different use cases.
CloudFront Functions excel at high-volume, simple transformations like cache key normalization and header manipulation at 1/6th the cost of Lambda@Edge. Lambda@Edge handles complex operations requiring network access, external APIs, or sophisticated business logic.
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: Making the Right Choice
Understanding the differences between CloudFront Functions and Lambda@Edge is essential for making cost-effective architectural decisions.
Feature Comparison
| Feature | CloudFront Functions | Lambda@Edge |
|---|---|---|
| Execution Location | 1600+ edge locations | 1600+ 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 | 1-3 seconds |
| KeyValueStore | Yes | No |
Cost Impact
For 10 billion monthly requests, the cost difference is significant:
- CloudFront Functions: 10,000M × 1,000
- Lambda@Edge: 10,000M × 6,000-$8,000
CloudFront Functions are 5-8x cheaper for simple use cases.
Decision Framework
CloudFront Functions: Optimized for Speed
CloudFront Functions execute at all 1600+ edge locations with sub-millisecond latency. They’re ideal for high-volume, simple operations that don’t require network access.
Use Case 1: Cache Key Normalization
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;
}
This normalization improved cache hit ratio from 45% to 78% in a production system, reducing origin requests by 60%.
Use Case 2: Security Headers
Adding security headers using CloudFront Functions is more cost-effective than Lambda@Edge.
// 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 3,000+ with Lambda@Edge.
Use Case 3: 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: Power and Flexibility
Lambda@Edge handles complex operations requiring network access, external APIs, or sophisticated business logic. The trade-off is higher cost and potential cold start latency.
Use Case 1: 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);
}
};
Use Case 2: 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.
Use Case 3: 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. Here’s what works to minimize them:
1. Minimize Package Size
// 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
};
2. Right-Size Memory Allocation
More memory equals more CPU, which reduces cold start time. Testing showed that 512 MB is often the sweet spot for Lambda@Edge functions with moderate dependencies.
3. Reduce External Dependencies
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 metrics from production systems:
- Empty function: 100-300ms
- With AWS SDK: 500-1000ms
- With Sharp (image processing): 1000-3000ms
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
Understanding cost structure helps make informed decisions.
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
1. Use CloudFront Functions When Possible: For operations like header manipulation and cache key normalization, CloudFront Functions offer 5-8x cost savings.
2. Optimize Lambda@Edge Memory: Right-size memory allocation using CloudWatch metrics. More memory can reduce execution time, offsetting higher memory costs.
3. Reduce Invocation Frequency: Use CloudFront cache policies effectively to minimize edge function invocations. Every cached response avoids a function invocation.
4. 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
Common Pitfalls and Solutions
1. Lambda@Edge Response Size Exceeded (502 Error)
Problem: Function returns response >1 MB, CloudFront returns 502.
Solution: Check response size before returning:
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`
})
};
}
2. Viewer Request Timeout (5 Seconds)
Problem: Viewer request Lambda@Edge function times out.
Solution: Use Promise.race for timeout protection:
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 4000)
);
const result = await Promise.race([
fetchExternalData(),
timeoutPromise
]);
3. Cache Key Inefficiency Creating Duplicate Objects
Problem: Cache hit ratio low, high origin requests.
Solution: Normalize query parameters:
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_20_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.
Key Takeaways
-
Choose the right service for the job: CloudFront Functions for 90% of use cases (headers, cache keys, simple logic), Lambda@Edge for complex requirements (APIs, authentication, image processing). Cost difference is 5-8x.
-
Cold starts affect user experience: Lambda@Edge cold starts impact users directly. Minimize package size, optimize initialization, use CloudFront Functions for latency-sensitive operations.
-
1 MB response limit is hard: Lambda@Edge cannot return responses >1 MB. Design architecture accordingly; stream large payloads through S3.
-
Logs are distributed globally: Lambda@Edge logs appear in multiple AWS regions. Use structured logging with correlation IDs, check x-amz-cf-pop header to find correct region.
-
Cache optimization is critical: Normalize cache keys with CloudFront Functions to improve hit ratio. Poor cache key design creates duplicate objects and increases origin load.
-
Security headers via CloudFront Functions: Adding HSTS, CSP, X-Frame-Options costs 6+ with Lambda@Edge.
-
KeyValueStore enables dynamic configuration: Update A/B test percentages, feature flags, and routing rules without redeploying functions. 5 MB storage, sub-millisecond reads.
-
Monitor costs continuously: Edge functions can scale to billions of invocations. Set CloudWatch alarms, review monthly costs, optimize aggressively.
-
Test failover scenarios: Edge functions are part of critical path. Implement graceful error handling, return original request on failure, monitor error rates.
-
Start simple, scale progressively: Begin with CloudFront managed policies, add CloudFront Functions for optimization, introduce Lambda@Edge only when necessary. Measure impact at each step.
Working with edge computing taught me that the right choice between CloudFront Functions and Lambda@Edge depends on specific requirements. Starting simple and adding complexity only when needed produces the most cost-effective and maintainable solutions.
Related posts
Practical approaches to managing Lambda Layer versions across dev, staging, and production environments with AWS CDK, including automated deployment pipelines and rollback strategies.
Multi-environment deployment strategies, performance optimization at scale, and cost management. Production insights and lessons learned with proper monitoring and incident response patterns.
A comprehensive technical guide to Amazon Cognito's advanced features including custom authentication flows, federation patterns, multi-tenancy architectures, migration strategies, and production-grade security implementation.
A comprehensive technical guide comparing AWS Secrets Manager and Systems Manager Parameter Store, demonstrating when to use each service with real-world implementation patterns.
A comprehensive guide to reducing AWS costs by 40-70% through systematic optimization using native AWS services, automation, and proven implementation patterns.