Improving time to first byte, an intro to S3 Multi Region Access Points, Lambda@Edge functions and CloudFront
Improving Time to First Byte (TTFB) globally with S3 Multi-Region Access Points, Lambda@Edge, and CloudFront.
It has been a few days since I wrote about the initial setup of this Astro site on: AWS Amplify. Honestly, I expected that setup to carry me for a good while. But after a bit of tinkering and just a little experimentation I stumbled into an unexpected revelation…
2025-12-03T02:32:51.248Z [ERROR]: !!! CustomerError: The size of the build
output (257224425) exceeds the max allowed size of 230686720 bytes. Please
reduce the size of your build output
(/codebuild/output/src1544707934/src/path/.amplify-hosting/compute/default) and try again.
That’s right. Building the site with Amplify comes with a hard limit of roughly 200–220 MB of storage. Once you cross that threshold, the build simply fails. I knew I’d run into it eventually with photography , but I managed to trigger it sooner than expected. All thanks to a few too many modules sneaking into the project. Go figure.
And this is how I came to create a Rube Goldberg Cloud Hosting Machine.
Bottom line: I’ve moved the site to a setup using multiple geographically distributed S3S3Simple Storage ServiceCloudAmazon Web Services scalable object storage offering 99.999999999% (11 9s) of data durability with fine-grained IAM policies and versioning. buckets for static hosting. Whenever I push or merge into the main branch, GitLab triggers a CI/CDCI/CDContinuous Integration & Continuous DeliveryDevOpsDevOps automation pipeline practice that automatically compiles, tests, and deploys software changes directly to staging or production environments. pipeline that builds the site and uploads the output to one primary bucket. From there, the content is automatically replicated to all the other regional buckets.
All of these buckets sit behind an S3 Multi Region Access Point (MRAP), a global endpoint that automatically routes traffic to the lowest latency region and provides seamless failover. As far as I can tell, the permissions side of this should live squarely in IAMIAMIdentity & Access ManagementSecurityA security framework of role-based and attribute-based policies controlling which identities (users, services, or roles) can perform actions on cloud resources. territory. However, there’s a gotcha: requests to a Multi Region Access Point need to be signed using the SigV4ASigV4AAWS Signature Version 4 AsymmetricSecurityAWS asymmetric request signing protocol using ECC public-private key pairs to authenticate multi-Region requests across S3 Multi-Region Access Points. signing mechanism when the traffic is coming through a CloudFront distribution. To handle that signing step, Lambda@Edge functions can be used to apply the appropriate request headers on the fly with the same low latency we have been optimizing for.
This blog has origins in:
-
Europe (Stockholm)
-
Europe (Paris)
-
Asia Pacific (Mumbai)
-
Asia Pacific (Sydney)
-
Asia Pacific (Tokyo)
-
US West (N. California)
-
US East (N. Virginia)
-
South America (São Paulo)
This has been done before.
There are plenty of articles out there that, in one form or another, rehash the official AWS guide: Building an active-active, latency-based application across multiple Regions The github repo that accompanies it—amazon-cloudfront-with-s3-multi-region-access-points—includes CloudFormation templates and a Python Lambda function, but (as you might guess) it doesn’t exactly work out of the box. Tracking down the right dependencies and figuring out which Python version it was originally meant for is… not straightforward.
Along the way, I found another excellent deep dive: Ultimate Guide to Multi-Region Access Points (MRAP).
They wrote their own Node.js function, and although I was tempted to try it, I kept digging. That’s when I stumbled on the MVP KIMBEOBWOO I decided to give that one a shot. I removed the original Python functions and deployed a clean Node.js Lambda instead.
Immediately, the cryptic error messages that had been plaguing me turned into something actually grokable. The first complaint was about GetBucketList. One quick IAM adjustment later, and I could finally see the bucket contents. Navigating directly to an HTML file even rendered the page. Huge progress.
I was close. The function worked, but it didn’t remove the requirement to explicitly specify object names like index.html at the domain apex or inside subdirectories. A couple of small tweaks, basically two if statements fixed that behavior. And just like that, everything came together.
Case in point: you’re reading this right now, and unless CloudFront served it from cache, your request just passed through my Lambda@Edge function.
My Humble Contribution
const crt = require("aws-crt");
const { sigV4ASign } = require("./sigv4a_sign");
/**
* Lambda handler
* @param {*} event
* @returns SigV4 signed request
*
* @see https://github.com/aws-samples/amazon-cloudfront-with-s3-multi-region-access-points
* @author KIMBEOBWOO
*/
exports.handler = async (event) => {
const { request } = event.Records[0].cf;
let config = {
service: "s3", // Empty for GET, could be mapped from request, if there is such case. E.g. request['body']['data']
region: "*", // For S3 Multi-Region Access Point it's '*' (e.g. all regions). Also, that's why SigV4A is required.
algorithm: crt.auth.AwsSigningAlgorithm.SigV4Asymmetric,
signature_type: crt.auth.AwsSignatureType.HttpRequestViaHeaders,
signed_body_header: crt.auth.AwsSignedBodyHeaderType.XAmzContentSha256,
provider: crt.auth.AwsCredentialsProvider.newDefault(),
};
let method = "GET";
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
}
// Check whether the URI is missing a file extension.
else if (!request.uri.includes('.')) {
request.uri += '/index.html';
}
let endpoint = `https://${request["origin"]["custom"]["domainName"]}${request["uri"]}`;
// CloudFront adds "X-Amz-Cf-Id" header after Origin request Lambda but before the request to the origin.
// Therefore it has to be part of the signing request.
let xAmzCfId = event["Records"][0]["cf"]["config"]["requestId"];
// Add SigV4A auth headers in the by CloutFront expected data structure.
let signedHeaders = sigV4ASign(method, endpoint, xAmzCfId, config)
._flatten()
.filter(([key]) => key.toLowerCase() !== "x-amz-cf-id")
.reduce((acc, [key, value]) => {
acc[key] = [{ key, value }];
return acc;
}, {});
// Add the signed headers to the request
request.headers = {
...request.headers,
...signedHeaders,
};
return request;
};
In Closing
I didn’t want to spin up yet another plain static site on a lone S3 bucket. This new setup felt more interesting, and honestly, I’m pretty happy with how it turned out. It was a fun experiment, and I learned quite a bit along the way.

About the author
Ed Stuurman
Infrastructure and reliability engineer based in Japan. Passionate about resilient systems, bare-metal hardware, cloud automation with Terraform, and all things homelab.
Contact
Thoughts or questions on this article?
Found a bug, tested a similar setup, or want to discuss the details? Drop a message below.