Skip to content
Back to blog
5 min read

Why I Used CloudFront Functions Instead of Lambda@Edge

Why a CloudFront Function was the right choice for canonical host redirects and URL rewriting, and what would change that decision.


When I set up the CDN for this site, two things needed to happen before S3 saw any request: redirect non-canonical domains to the blog subdomain, and append index.html to clean URL paths. Both are string manipulation. Neither needs network access, environment variables, or anything beyond the request URI and host header.

AWS offers two options for running code at the CloudFront edge: CloudFront Functions and Lambda@Edge. The choice between them came down to what the logic actually needed to do.

The problem#

The site is a static export hosted on S3 behind CloudFront. Three domains point to the same distribution: the apex (edwardsmatt.com), www.edwardsmatt.com, and the raw CloudFront domain (d1234.cloudfront.net). The canonical host is blog.edwardsmatt.com. Any request arriving on the other three needs a 301 redirect.

The second problem is URL structure. Next.js static export produces files like out/posts/some-slug/index.html, but visitors and search engines expect /posts/some-slug/. Something needs to map between the two. S3 serves files by exact key, so /posts/some-slug/ returns nothing unless the request is rewritten before it reaches the origin.

Why CloudFront Functions#

CloudFront Functions run at the edge point of presence, the same location that serves cached content. Lambda@Edge runs at the regional edge cache, a smaller set of locations one hop further from the viewer. For logic that executes on every viewer request, the distinction matters: CloudFront Functions add sub-millisecond latency, while Lambda@Edge adds the overhead of a full Lambda invocation.

The constraints of CloudFront Functions are significant. There is no general network access, no file system, and the runtime is a constrained JavaScript environment (this function uses JavaScript runtime 2.0(opens in a new tab), which extends the original ES 5.1 baseline with a selection of ES6 features). AWS has since added CloudFront KeyValueStore for lightweight configuration data, but this function does not need even that. The entire function is a host-header check and a URI string operation. Nothing in the logic benefits from a full Node.js runtime.

The function attaches to the default cache behaviour as a viewer-request trigger. This is also a natural fit for CloudFront Functions, which only run on viewer events. Lambda@Edge would be needed for origin-request and origin-response hooks, but this logic has no reason to run at the origin. The function does not attach to the /_next/* behaviour because those requests already have explicit file paths with extensions and do not need rewriting.

The implementation#

function serializeQueryString(qs) {
    var keys = Object.keys(qs);
    if (keys.length === 0) return '';
    var parts = [];
    for (var i = 0; i < keys.length; i++) {
        var key = keys[i];
        var entry = qs[key];
        if (entry.multiValue) {
            for (var j = 0; j < entry.multiValue.length; j++) {
                parts.push(key + '=' + entry.multiValue[j].value);
            }
        } else {
            parts.push(key + '=' + entry.value);
        }
    }
    return '?' + parts.join('&');
}
 
function handler(event) {
    var request = event.request;
    var host = request.headers.host.value;
    var uri = request.uri;
 
    if (host === 'edwardsmatt.com'
        || host === 'www.edwardsmatt.com'
        || host.endsWith('.cloudfront.net')) {
        var qs = serializeQueryString(request.querystring);
        return {
            statusCode: 301,
            statusDescription: 'Moved Permanently',
            headers: {
                'location': { value: 'https://blog.edwardsmatt.com' + uri + qs }
            }
        };
    }
 
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    } else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }
 
    return request;
}

The domain redirect uses 301 rather than 302. The canonical host is not going to change, and a permanent redirect lets browsers and crawlers cache the result.

The redirect preserves query parameters because CloudFront Functions separate uri from querystring in the event object. Without the serializer, any tracking or analytics parameters on the original URL would be silently dropped.

The URL rewriting uses !uri.includes('.') to distinguish directory paths from file requests. This works because the site's current URL design avoids dots in route segments, so a dot remains a practical signal for a file request rather than a directory-style path. A request for /posts/some-slug/ gets index.html appended. A request for /favicon.ico or /_next/static/chunks/abc123.js passes through unchanged.

What I gave up#

CloudFront Functions cannot modify the response body. If I needed to transform responses rather than just rewrite requests, Lambda@Edge would be the only edge-compute option. For response headers, CloudFront provides a separate mechanism: response headers policies configured in Terraform. The security headers (HSTS, X-Frame-Options, Content-Type-Options) are applied through that policy rather than in the function.

The function also cannot call external services. Authentication that requires checking a token against an identity provider, or routing logic that depends on a database lookup, would not fit here.

What would change the decision#

Lambda@Edge would become necessary if the site needed request-body access, third-party libraries, network calls to external services, or origin-request processing that depends on more than lightweight header and URI manipulation. CloudFront Functions can now handle some origin-routing scenarios for origins already defined on the distribution, so the boundary is not simply "any origin selection means Lambda@Edge". For this site, though, the logic still sits comfortably on the CloudFront Functions side of that line.

CloudFront Functions and Lambda@Edge can coexist on the same distribution, attached to different event types. Adding Lambda@Edge for an origin-request trigger later would not require replacing the CloudFront Function on viewer-request. The choice is not permanent.

For now, a short function running at the edge point of presence, with no Lambda to deploy, no IAM role to manage, and no regional replication to wait for, is the right tool for the job.

For a full comparison of execution limits, pricing, and supported triggers, the AWS documentation on choosing between CloudFront Functions and Lambda@Edge(opens in a new tab) covers the differences in detail.