Skip to main content

Command Palette

Search for a command to run...

Custom Global Routing with Cloudfront Functions

Updated
•8 min read•View as Markdown
Custom Global Routing with Cloudfront Functions
L

Building stuff on AWS! Professional AWS cloud consultant/engineer focussing on innovation for enterprise, SME, and start-ups. 6x AWS certified.

CloudFront Functions let you run lightweight JavaScript code at the edge, before a request ever reaches your origin. In this post I'll walk through how to use them for custom multi-region routing: cookie-based stickiness, geo-fallback, and health-aware origin selection.


Architecture Overview

Here's how the routing flow works end to end:

The client hits a single CloudFront distribution. The viewer-request function runs at the edge, reads healthy regions from the KV Store, picks a target region based on cookie or country header, then routes the request to the right regional ALB.


CloudFront Functions vs Lambda@Edge

To start off, a brief recap: CloudFront offers two ways to run code at the edge: CloudFront Functions (CFF) and Lambda@Edge. For routing logic, CFF is almost always the right choice.

CloudFront Functions Lambda@Edge
Execution time limit 1ms 5s (viewer) / 30s (origin)
Runtime JS only (ES5.1+, ES modules in 2.0) Node.js, Python
KV Store access ✅ Yes ❌ No
Async / await ✅ (runtime 2.0) ✅
Cost ~1/6th the price Higher
Trigger points Viewer request/response only Viewer + origin request/response

Three CFF capabilities make routing possible:

  • cf.selectRequestOriginById(id) dynamically selects a named CloudFront origin at request time, without redirecting the client

  • KV Store is a low-latency key-value store readable from CFF. It's ideal for storing health state, like which regions are currently healthy

  • CloudFront viewer headers give you metadata like cloudfront-viewer-country and cloudfront-viewer-city injected automatically by CloudFront


The Routing Pattern

The function implements a three-tier routing decision:

  1. Cookie wins. If the request carries a custom cf-region cookie pointing to a healthy region, use it.

  2. Geo-fallback. If no cookie, infer the region from cloudfront-viewer-country.

  3. KV health check. Only route to regions listed as healthy in the KV Store.

import cf from 'cloudfront';
const kvs = cf.kvs();

async function handler(event) {
  var request = event.request;
  var headers = request.headers;

  // 1. Fetch healthy regions from KV Store
  var healthyRegions = ['eu-central-1'];
  try {
    var v = await kvs.get('healthy_regions');
    if (v && typeof v === 'string') {
      healthyRegions = v.split(',');
    }
  } catch (e) { /* key missing, keep default */ }

  // 2. Default to first healthy region
  var targetRegion = healthyRegions[0] ?? 'eu-central-1';

  // 3. Cookie-based stickiness
  if (request.cookies?.['cf-region']?.value) {
    var cookieRegion = request.cookies['cf-region'].value.toLowerCase();
    if (healthyRegions.includes(cookieRegion)) targetRegion = cookieRegion;

  // 4. Geo-based selection
  } else {
    var country = headers['cloudfront-viewer-country']?.value;
    var isUS = ['US','CA','MX','BR' /* ... */].includes(country);
    var isAP = ['JP','AU','SG','IN' /* ... */].includes(country);

    if (isUS && healthyRegions.includes('us-east-1'))           targetRegion = 'us-east-1';
    else if (isAP && healthyRegions.includes('ap-northeast-1')) targetRegion = 'ap-northeast-1';
    else if (healthyRegions.includes('eu-central-1'))           targetRegion = 'eu-central-1';
  }

  // 5. (Optional) Strip path prefix before forwarding to origin.
  // Uncomment if your origin doesn't expect the CloudFront path prefix.
  // var stripPrefix = '/api';
  // if (request.uri.startsWith(stripPrefix)) {
  //   request.uri = request.uri.substring(stripPrefix.length) || '/';
  // }

  // 6. Select origin and tag the request for observability
  cf.selectRequestOriginById('api_alb_' + targetRegion);
  request.headers['x-selected-region'] = { value: targetRegion };

  return request;
}

The Terraform below wires the three key pieces together: the KV Store, the function (with KV Store association), and the CloudFront origins.

# KV Store — holds the healthy_regions key updated by your health-check process
resource "aws_cloudfront_key_value_store" "region_health" {
  name    = "\({var.project_name}-\){var.environment}-region-health"
  comment = "Region health status for manual routing control"
}

# CloudFront Function — associates the KV Store so it can be read at runtime
resource "aws_cloudfront_function" "api_routing" {
  name                         = "\({var.project_name}-\){var.environment}-api-routing"
  runtime                      = "cloudfront-js-2.0"
  comment                      = "Routes /api requests to regional ALB origins"
  publish                      = true
  key_value_store_associations = [aws_cloudfront_key_value_store.region_health.arn]
  code                         = file("${path.module}/functions/api-routing.js")
}

# One VPC origin per regional ALB — origin ID must match what the function passes
# to selectRequestOriginById (e.g. "api_alb_eu-central-1")
resource "aws_cloudfront_distribution" "main" {
  # ... your existing distribution config ...

  origin {
    origin_id   = "api_alb_eu-central-1"
    domain_name = var.eu_alb_dns_name
    vpc_origin_config {
      vpc_origin_id = aws_cloudfront_vpc_origin.eu.id
    }
  }

  origin {
    origin_id   = "api_alb_us-east-1"
    domain_name = var.us_alb_dns_name
    vpc_origin_config {
      vpc_origin_id = aws_cloudfront_vpc_origin.us.id
    }
  }

  origin {
    origin_id   = "api_alb_ap-northeast-1"
    domain_name = var.ap_alb_dns_name
    vpc_origin_config {
      vpc_origin_id = aws_cloudfront_vpc_origin.ap.id
    }
  }

  ordered_cache_behavior {
    path_pattern             = "/api/*"
    target_origin_id         = "api_alb_eu-central-1" # default; overridden by the function
    viewer_protocol_policy   = "redirect-to-https"
    allowed_methods          = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
    origin_request_policy_id = aws_cloudfront_origin_request_policy.forward_all_except_host.id

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.api_routing.arn
    }
  }
}

One thing to watch: the origin ID strings in Terraform (e.g. api_alb_eu-central-1) must exactly match what the function passes to selectRequestOriginById. A mismatch causes a silent fallback to the default origin.


Use Cases

Cookie-based stickiness Once a user lands on a region, set a cf-region response cookie via a separate viewer-response function. Subsequent requests from that user stick to that region as long as it remains healthy. Useful for session affinity or A/B testing.

Geo-based defaulting Using cloudfront-viewer-country, you can assign users to their nearest region without any client-side logic. No latency lookup needed; CloudFront injects the header automatically.

Health-aware failover Store a comma-separated list of healthy regions in the KV Store (e.g. eu-central-1,us-east-1). An external health-check process updates this key when a region degrades. The function reads it on every request, so no redeployment is needed to reroute traffic.
To update the KV Store manually, first get the current ETag, then write the new value:

# Find your KVS ARN
aws cloudfront list-key-value-stores --query "KeyValueStoreList.Items[].ARN" --output text

# Read current value + ETag
aws cloudfront-keyvaluestore get-key \
  --kvs-arn="arn:aws:cloudfront::ACCOUNT:key-value-store/KVS-ID" \
  --key="healthy_regions"

# Write new value (use ETag from above)
aws cloudfront-keyvaluestore put-key \
  --kvs-arn="arn:aws:cloudfront::ACCOUNT:key-value-store/KVS-ID" \
  --key="healthy_regions" \
  --value="eu-central-1,us-east-1" \
  --if-match="ETAG_FROM_GET_RESPONSE"

The --if-match flag is required. It's an optimistic lock to prevent overwriting concurrent updates.


Debugging

There are two separate log sources to be aware of.

Function logs: console.log() output from the CFF itself. Written automatically to CloudWatch Logs under /aws/cloudfront/function/<function-name> in us-east-1. Use these to trace routing decisions. Remove verbose logging before production; execution time is tight.

Access logs: per-request logs for the distribution itself (status codes, latency, cookies, country, etc.). These are not enabled by default. Wire them up in Terraform using CloudWatch standard logging (v2):

resource "aws_cloudwatch_log_group" "cloudfront_access_logs" {
  name              = "/aws/cloudfront/my-distribution-access-logs"
  retention_in_days = 7
  provider          = aws.us_east_1 # must be us-east-1 for CloudFront logging
}

resource "aws_cloudwatch_log_delivery_source" "cloudfront" {
  name         = "my-cloudfront-access-logs"
  log_type     = "ACCESS_LOGS"
  resource_arn = "arn:aws:cloudfront::ACCOUNT_ID:distribution/DIST_ID"
  provider     = aws.us_east_1
}

resource "aws_cloudwatch_log_delivery_destination" "cloudfront" {
  name          = "my-cloudfront-logs-destination"
  output_format = "json"
  delivery_destination_configuration {
    destination_resource_arn = aws_cloudwatch_log_group.cloudfront_access_logs.arn
  }
  provider = aws.us_east_1
}

resource "aws_cloudwatch_log_delivery" "cloudfront" {
  delivery_source_name     = aws_cloudwatch_log_delivery_source.cloudfront.name
  delivery_destination_arn = aws_cloudwatch_log_delivery_destination.cloudfront.arn
  record_fields            = ["date", "time", "c-ip", "c-country", "cs-uri-stem",
                              "sc-status", "time-to-first-byte", "x-edge-location",
                              "cs(Cookie)", "x-host-header"]
  provider = aws.us_east_1
}

Cookie logging is off by default for privacy reasons. Including cs(Cookie) in record_fields is not enough on its own, you also need to enable it on the distribution itself: CloudFront console > your distribution > Logging > Manage > Settings > turn on Cookie logging. Disable again when done.

Once access logs are flowing, a useful CWLI query to find the slowest endpoints per edge location:

FIELDS `cs-uri-stem`, `time-to-first-byte`, `x-edge-location`
| FILTER ispresent(`time-to-first-byte`) and ispresent(`cs-uri-stem`)
| STATS pct(`time-to-first-byte`, 80) as p80_latency, count(*) as request_count
    by `cs-uri-stem`, `x-edge-location`
| SORT request_count DESC
| LIMIT 500
| SORT p80_latency DESC
| LIMIT 10

To trace which region served a specific request, filter on the x-selected-region header that the function sets on every request, downstream services can log it too for end-to-end tracing.


Common Mistakes and How to Avoid Them

1. Execution time limit CloudFront Functions must complete within 1ms of compute time. KV Store reads are fast but not free. Keep your logic simple and avoid multiple sequential KV lookups. Check the AWS docs for current limits.

2. No origin request trigger CFF only runs on viewer request and viewer response, not origin request. If you need to modify the request after cache evaluation (e.g. to sign requests to your origin), you need Lambda@Edge for that leg.

3. KV Store consistency KV Store updates propagate globally in seconds but are eventually consistent. During a health failover, a small window exists where some edge nodes may still route to a degraded region. Build your origins to handle this gracefully.

More from this blog

Luuk's AWS engineering blog

14 posts

Building production environments for enterprise, SME and startups! Experience with corporate cloud centers of excellence, migrations, green field development and serverless data engineering.