Saving 94% on Serverless Costs: Replacing AWS API Gateway with Cloudflare Workers

Cut serverless costs by 94% by replacing AWS API Gateway with Cloudflare Workers. See the code, architecture, and cost breakdown for high-scale apps.

Saving 94% on Serverless Costs: Replacing AWS API Gateway with Cloudflare Workers

If you deploy serverless functions on AWS, you likely know the routine: spin up a Lambda function, place an Amazon API Gateway in front of it, and forget it exists. It is the industry standard—reliable, effortless to set up, and “pay-per-use.”

But “pay-per-use” is a double-edged sword. When your application succeeds beyond expectations, those costs can spiral out of control. Recently, while planning a Laravel deployment, I audited the projected costs. I refuse to take infrastructure defaults for granted; just because a service is the standard choice doesn’t mean it is efficient for every stage of growth.

I wanted to avoid becoming another entry in the Serverless Horror Stories archive. My goal was simple: high performance and financial harmony. In this post, I will share how I swapped AWS API Gateway for Cloudflare Workers, achieving a 94% cost reduction in high-traffic scenarios without sacrificing speed.

The Problem: The High Cost of Scale

AWS API Gateway acts as the doorman for your serverless functions. It manages traffic, handles throttling, and routes requests. For startups finding their product-market fit, this is excellent because the setup time is minimal.

However, the pricing model punishes scale. As of my last check in the US East (N. Virginia) region, you pay roughly $1.00 per million requests for HTTP APIs and significantly more for REST APIs. While this sounds cheap initially, the bill accumulates aggressively as you grow.

The Math

To visualize the financial impact, I modeled the costs for two scenarios: a moderate scale of 1 million requests per month and a high-scale target of 1 billion requests per month (assuming a 100 KB average request size).

Scenario 1: 1 Million Requests/Month

ServiceMonthly Cost (USD)
AWS HTTP API~$1.00
AWS REST API~$3.50

Scenario 2: 1 Billion Requests/Month

ServiceMonthly Cost (USD)
AWS HTTP API~$930.00
AWS REST API~$3,033.10

For low-traffic websites, the cost is negligible. However, at 1 billion requests, the difference is staggering. Paying over $3,000 a month merely to route traffic to a compute service felt excessive. This “routing tax” threatened to dwarf the cost of the actual Lambda compute. I needed an alternative that prioritized efficiency without introducing complex infrastructure.

The Solution: Cloudflare Workers as a Proxy

The solution became clear when I analyzed Cloudflare Workers. Unlike AWS API Gateway, Cloudflare’s platform is designed for high-volume edge computing with a much friendlier pricing curve for massive request volumes.

I realized a Cloudflare Worker could act as a lightweight, programmable proxy sitting in front of the AWS Lambda Function URL. Instead of paying AWS to route traffic, I could utilize Cloudflare’s global network to handle incoming requests, apply middleware (like CORS, Authentication, or Headers), and forward the request directly to the Lambda Function URL.

Why this approach works:

  • Lower Latency: Cloudflare runs at the edge, terminating the initial connection much closer to the user.
  • Cheaper at Scale: Cloudflare’s pricing per million requests is significantly lower, and their standard plan includes a generous request bundle.
  • Flexibility: We aren’t locked into AWS’s configuration. I used Hono, a web framework that handles routing and headers seamlessly.

Implementation

I leveraged TypeScript and Hono to build the worker. This setup allows us to handle static assets, cache headers, and proxy requests effortlessly.

1. The Proxy Logic

We define a middleware handler that intercepts the request and forwards it to the LAMBDA_FUNCTION_URL. The secret sauce here is preserving the original host headers so the backend application (in this case, Laravel) generates self-referencing URLs correctly.

Here is the core logic in function/[[route]].ts:

import { Hono, MiddlewareHandler as _MiddlewareHandler } from "hono";
import { cache } from "hono/cache";
import { handle, serveStatic } from "hono/cloudflare-pages";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger";

type Bindings = {
  LAMBDA_FUNCTION_URL: string;
  DB: D1Database;
};

type MiddlewareHandler = _MiddlewareHandler<{ Bindings: Bindings }>;

function createProxiedUrl(requestUrl: URL, targetUrl: URL) {
  const url = new URL(requestUrl);
  url.host = targetUrl.host;
  url.protocol = targetUrl.protocol;
  url.port = targetUrl.port;
  return url;
}

const handleProxiedRequest: MiddlewareHandler = async (c) => {
  const { LAMBDA_FUNCTION_URL } = c.env;

  if (!LAMBDA_FUNCTION_URL) {
    throw new HTTPException(502, { message: "Internal Server Error" });
  }

  const requestUrl = new URL(c.req.url);
  const proxiedUrl = createProxiedUrl(requestUrl, new URL(LAMBDA_FUNCTION_URL));

  // Create the request that will be sent to the AWS Lambda
  const newRequest = new Request(proxiedUrl.toString(), c.req.raw);

  // Critical: Forward the original Host so the backend knows the real domain
  newRequest.headers.set("Host", requestUrl.hostname);
  newRequest.headers.set("X-Forwarded-Host", requestUrl.hostname);

  // Pass Cloudflare Metadata (Geo, etc.) to Lambda via Headers
  const cloudflareMetadata = c.req.raw.cf || {};

  for (const key in cloudflareMetadata) {
    const value = cloudflareMetadata[key];
    const headerKey = `X-CF-${key}`;

    if (typeof value === "string") {
      newRequest.headers.set(headerKey, value);
    } else if (typeof value !== "undefined" && typeof value === "object") {
      newRequest.headers.set(headerKey, JSON.stringify(value));
    }
  }

  // Send the request to AWS Lambda and get back the response
  const response = await fetch(newRequest);

  // Create a new response to modify headers if needed (e.g. removing X-Powered-By)
  const newResponse = new Response(response.body, response);
  newResponse.headers.delete("X-Powered-By");

  return newResponse;
};

// ... Additional static handling code below
export const onRequest = handle(app);

I love this setup because of how easily we handle cf object properties. By iterating over c.req.raw.cf, we inject valuable geolocation and request data into headers prefixed with X-CF-. This gives our Lambda function context about the user (Country, City, Latitude) without requiring complex AWS integrations.

2. Handling Static Assets

AWS Lambda is notoriously tricky when returning binary files like images. It often requires base64 encoding, increasing payload size and latency. To solve this, we serve static assets directly from the edge using Cloudflare Pages logic.

We configure the worker to intercept paths like /build/*, /css/*, and /js/* and serve them from Cloudflare’s cache, bypassing the Lambda function entirely.

const handleStatic: MiddlewareHandler = async (c, next) => {
  const response = await serveStatic()(c, next);
  if (!response || response?.status === 404) {
    return c.notFound();
  }
  return new Response(response.body, response);
};

// Aggressive caching for build assets
app.all(
  "/build/*",
  cache({
    cacheName: "build-static",
    cacheControl: "public, max-age=31536000, immutable",
  }),
  handleStatic,
);

app.all("/*", handleProxiedRequest);

To maximize the benefits of the Cloudflare CDN, we also configure the _headers and _routes.json files. This step is crucial for defining security headers that protect the application.

File _headers

/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  X-UA-Compatible: ie=edge
  X-XSS-Protection: 1; mode=block
  Feature-Policy: fullscreen 'self'; camera 'none'; geolocation 'none'; microphone 'none'
  Referrer-Policy: same-origin

/favicon.ico
  Cache-Control: public, max-age=86400

/robots.txt
  Cache-Control: public, max-age=86400

/sw.js
  Cache-Control: no-cache

File _routes.json

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/favicon.ico", "/robots.txt", "/sw.js"]
}

3. Deploying with GitHub Actions

Automation isn’t optional; it’s survival. Manual deployments are a recipe for disaster. We established a CI/CD pipeline using GitHub Actions to deploy the Laravel/PHP backend to AWS Lambda (via Bref) and the frontend proxy to Cloudflare simultaneously.

The workflow is critical for keeping the two environments in sync:

  1. Build Frontend: Compile assets.
  2. Deploy Backend: Push the PHP application to AWS Lambda using Serverless Framework/Bref.
  3. Deploy Proxy: Push the worker code and static assets to Cloudflare Pages.

Here is the deploy.yml configuration:

name: Deploy

on: workflow_dispatch

jobs:
  deploy-dev:
    name: AWS Lambda/Cloudflare workers
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - uses: actions/setup-node@v4
        with:
          node-version: "18"
          cache: "npm"

      - name: Install node modules
        run: npm install

      - name: Build
        run: npm run build

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"

      - name: Install Composer dependencies
        uses: "ramsey/composer-install@v2"
        with:
          composer-options: "--prefer-dist --optimize-autoloader --no-dev"

      - name: Build
        # "php artisan config:cache" it's not necessary.
        # Bref will do it for us.
        run: |
          php artisan event:cache
          php artisan route:cache
          php artisan view:cache
          php artisan icons:cache

      - name: Deploy to AWS Lambda
        uses: serverless/github-action@v3
        with:
          args: deploy --stage=dev
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Run migrations
        run: npx serverless bref:cli --args="migrate --force"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: project-name
          branch: main
          directory: ./public
          wranglerVersion: "3"

The Results

The financial impact was immediate. By switching the routing layer to Cloudflare, the cost dropped dramatically for our high-scale scenario.

Scenario A: 1 Billion Requests

When we crunch the numbers for a high-traffic enterprise scenario, the difference is undeniable.

ServiceMonthly Cost (USD)
AWS HTTP API$930.00
AWS REST API$3,033.10
Cloudflare Workers$192.85

Proxying via Cloudflare Workers adds roughly 30ms of latency on average—a negligible trade-off for a total cost of $192.85. That is 80% cheaper than API Gateway HTTP API and 94% cheaper than API Gateway REST API.

Scenario B: 1 Million Requests (The Trade-off)

However, we must be transparent about when not to use this. If you are operating at a smaller scale, AWS might actually be cheaper and simpler.

ServiceMonthly Cost (USD)
AWS HTTP API~$1.00
AWS REST API~$3.50
Cloudflare Workers~$5.00

At low volumes, the $5 minimum for the Cloudflare Workers Paid plan exceeds the pennies you would pay AWS. This solution is strictly an optimization for scale. If your app gets low traffic, stick with API Gateway.

Lessons Learned & Trade-offs

I never stop searching for better ways to accomplish my mission, but “better” always comes with trade-offs. Here is what you need to consider before migrating:

  • Development Complexity:
  • Local Dev: This architecture complicates local development. You effectively need two processes running: one for your backend and a second Node.js process to emulate the Cloudflare Worker proxy.
  • CI/CD Coordination: You must ensure the LAMBDA_FUNCTION_URL is correctly passed to the Cloudflare Worker during deployment, requiring careful environment variable management.
  • Egress Fees: While Cloudflare Workers (non-enterprise) generally don’t charge for egress, AWS does charge for data going out from Lambda to the internet (which includes the response back to Cloudflare). This bill will exist regardless of your gateway choice.
  • Cold Starts: You are chaining two serverless technologies. While Cloudflare Workers have a near-zero cold start, your AWS Lambda still needs to wake up. The added hop can slightly exacerbate the “cold start” feeling for the very first user.
  • Load Balancing: For extremely high-scale scenarios, Laravel Vapor and Bref recommend using an AWS Application Load Balancer (ALB). While robust, ALBs have a minimum cost that can be higher than the Workers approach for sporadic traffic.

Closing Thoughts

This architecture allowed me to discover alternatives to scale efficiently without the fear of a runaway AWS bill. It puts the control back in my hands, allowing for innovation with both quality and harmony.

If you are facing similar scaling issues, I encourage you to try this approach. Replacing a core AWS service might seem daunting, but the savings and flexibility are well worth the effort.

Have you tried replacing API Gateway? Leave your comment below or share your own cost-optimization wins.