Improving time to first byte, an intro to S3 Multi Region Access Points, Lambda@Edge functions and CloudFront

Because sometimes a straightforward setup does not suffice

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 S3 buckets for static hosting. Whenever I push or merge into the main branch, GitLab triggers a CI/CD 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, 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 IAM territory. However, there’s a gotcha: requests to a Multi Region Access Point need to be signed using the SigV4A 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.