---
title: "Cloudflare Workers free tier: keep static files out of the API budget"
date: 2026-11-02
description: "Workers Free allows 100,000 execution requests a day; static asset requests are free. Build a site and API with routing checked locally."
tags:
  - "free-tier"
  - "side-projects"
  - "cloud-infrastructure"
---

Cloudflare Workers Free can serve a static site and a small API from one project. The useful distinction is that **static asset requests are free and unlimited, while requests that invoke Worker code use the execution allowance**. Putting code in front of every page, stylesheet and script changes that split.

The Free execution allowance is **100,000 requests per day across the account**, with **10 ms of CPU time per invocation** and **128 MB memory**. That suits small request handlers. It does not turn Workers into a free general-purpose server. [Cloudflare’s pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [platform limits](https://developers.cloudflare.com/workers/platform/limits/) are the sources for those conditions, checked on **27 October 2026**.

Here is a complete plain-JavaScript site with one JSON endpoint. Its routing was checked in the local Workers runtime. No Cloudflare deployment, account-limit exhaustion or hosted CPU benchmark was performed.

## Give the static files their own route

The project has two jobs: serve three files and answer `GET /api/health`. The endpoint is deliberately a teaching example; it does not monitor another service or save anything.

By default, [a matching static asset is served before the Worker script](https://developers.cloudflare.com/workers/static-assets/routing/worker-script/). The configuration below also reserves `/api` and `/api/*` for code. That makes an API typo return a JSON 404 instead of accidentally finding an asset at the same path.

| Request | Handler in this example |
| --- | --- |
| `/`, `/styles.css`, `/app.js` | Static asset serving |
| `GET /api/health` | Worker returns JSON |
| `/api/missing` or `/api` | Worker returns JSON 404 |
| `POST /api/health` | Worker returns 405 with `Allow: GET` |
| A missing non-API path | Worker returns a plain 404 |

Unknown paths still matter: a request that misses the static files can reach the Worker. Asset-first routing does not make every request outside `/api` free execution, and it is not a way to protect private files. This example is public.

## Make the project

You need Node.js and npm. Create this structure:

```text
site-and-api/
  package.json
  wrangler.jsonc
  src/
    index.js
  public/
    index.html
    app.js
    styles.css
```

Use this `package.json`:

```json
{
  "name": "workers-site-and-api-example",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wrangler dev --local --ip 127.0.0.1"
  },
  "devDependencies": {
    "wrangler": "4.107.0"
  }
}
```

The local checks used Wrangler **4.107.0**, Node **26.2.0**, and workerd **1.20260701.1**. The compatibility date below is intentionally within that runtime’s supported range; it is not a claim that the latest date was tested. If you change Wrangler or the date, rerun the route checks rather than assuming identical behavior.

In `wrangler.jsonc`:

```jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "workers-site-and-api-example",
  "main": "src/index.js",
  "compatibility_date": "2026-07-01",
  "send_metrics": false,
  "assets": {
    "directory": "./public",
    "not_found_handling": "none",
    "run_worker_first": ["/api", "/api/*"]
  }
}
```

The important setting is the selective `run_worker_first` array. Setting it to `true` instead would run the Worker before every request, including matching assets. That can be appropriate for authentication or transforming responses, but it consumes execution requests. [The asset billing documentation](https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/) describes that boundary.

There is no single-page-app fallback here. Unknown URLs should be errors. A client-side router changes that decision and needs separate checks so a missing API endpoint does not return the HTML shell with a misleading 200.

Put the handler in `src/index.js`:

```javascript
export default {
  fetch(request) {
    const { pathname } = new URL(request.url);
    // A local teaching aid: asset hits do not reach this line.
    console.log("worker-request", request.method, pathname);

    const json = (data, status = 200, headers = {}) =>
      Response.json(data, {
        status,
        headers: { "Cache-Control": "no-store", ...headers },
      });

    if (pathname === "/api" || pathname.startsWith("/api/")) {
      if (pathname !== "/api/health") {
        return json({ error: "API route not found" }, 404);
      }
      if (request.method !== "GET") {
        return json({ error: "Use GET for this endpoint" }, 405, { Allow: "GET" });
      }
      return json({ ok: true, message: "Hello from a Worker." });
    }

    return new Response("Not found", {
      status: 404,
      headers: { "Content-Type": "text/plain; charset=utf-8" },
    });
  },
};
```

The console line is a local teaching aid. A matching static file should never reach it. The handler does not accept uploads, call another API or need an assets binding: the platform has already had its chance to serve a matching file before this code runs, except on the reserved API paths.

Now `public/index.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>A static page and a Worker API</title>
    <link rel="stylesheet" href="/styles.css">
    <script type="module" src="/app.js"></script>
  </head>
  <body>
    <main>
      <p class="eyebrow">Cloudflare Workers example</p>
      <h1>A static page.<br>A small JSON API.</h1>
      <p>The HTML, CSS and JavaScript are static files. The button asks
        <code>/api/health</code> for a response from the Worker.</p>
      <button id="check" type="button">Check the API</button>
      <p id="status" role="status" aria-live="polite">Ready to check.</p>
      <pre id="result" aria-label="API response">No request yet.</pre>
      <noscript><p>JavaScript is off. You can open <a href="/api/health">the JSON endpoint</a> directly.</p></noscript>
    </main>
  </body>
</html>
```

And `public/app.js`:

```javascript
const button = document.querySelector("#check");
const status = document.querySelector("#status");
const result = document.querySelector("#result");

button.addEventListener("click", async () => {
  button.disabled = true;
  status.textContent = "Checking the API…";
  result.textContent = "Waiting for a response.";

  try {
    const response = await fetch("/api/health", {
      signal: AbortSignal.timeout(8000),
    });
    if (!response.ok) throw new Error(`The API returned HTTP ${response.status}.`);
    const data = await response.json();
    if (data?.ok !== true) throw new Error("The API returned an unexpected response.");
    result.textContent = JSON.stringify(data, null, 2);
    status.textContent = "The Worker replied successfully.";
  } catch (error) {
    status.textContent = "The check failed. You can try again.";
    const messages = {
      TimeoutError: "The API did not reply within eight seconds. Check that the local server is running.",
      TypeError: "Could not reach the API. Check that the local server is running, then try again.",
      SyntaxError: "The API did not return valid JSON. Check the endpoint, then try again.",
    };
    result.textContent = messages[error.name] || error.message;
  } finally {
    button.disabled = false;
  }
});
```

The button checks both the HTTP status and the response shape. A request failure leaves a readable message and enables another attempt. The eight-second timeout is an application choice, not the Workers CPU allowance.

Use this small `public/styles.css`:

```css
:root { color-scheme: light dark; font-family: system-ui, sans-serif; --ink: #17332c; --paper: #f4f2e9; --panel: white; --line: #a2b5ac; --link: #165b46; --focus: #a34200; color: var(--ink); background: var(--paper); }
@media (prefers-color-scheme: dark) {
  :root { --ink: #e0ede6; --paper: #14231e; --panel: #1d3028; --line: #6c8779; --link: #9addbb; --focus: #ffb77e; }
}
body { margin: 0; }
main { max-width: 42rem; margin: 8vh auto; padding: 1.5rem; }
.eyebrow { text-transform: uppercase; letter-spacing: .09em; font-size: .8rem; font-weight: 700; }
h1 { font-size: clamp(2rem, 7vw, 3.6rem); line-height: 1.05; }
p { line-height: 1.6; }
button { font: inherit; font-weight: 700; color: white; background: #165b46; border: 0; border-radius: .4rem; padding: .8rem 1rem; cursor: pointer; }
button:disabled { opacity: .7; cursor: wait; }
button:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
pre { padding: 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: .4rem; white-space: pre-wrap; overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; }
a { color: var(--link); }
```

## Check the boundary, not just the happy response

From `site-and-api`, install the development dependency and start locally:

```sh
npm install
npm run dev
```

Open the local address printed by Wrangler, normally `http://127.0.0.1:8787`. These commands start the local runtime; they do not deploy a Worker. The page’s button should display:

```json
{"ok":true,"message":"Hello from a Worker."}
```

In another terminal, check the response status, content type and error cases. Watch the Wrangler terminal while these run: the first three requests should print no `worker-request` line; the API and missing-path requests should.

```sh
curl -i http://127.0.0.1:8787/
curl -i http://127.0.0.1:8787/styles.css
curl -i http://127.0.0.1:8787/app.js
curl -i http://127.0.0.1:8787/api/health
curl -i http://127.0.0.1:8787/api/missing
curl -i http://127.0.0.1:8787/api
curl -i -X POST http://127.0.0.1:8787/api/health
curl -i http://127.0.0.1:8787/missing
```

The local checks compared each request with the `worker-request` console messages. HTML, CSS and JavaScript loaded without entering the handler; the API and missing-path cases entered it. A 200 response alone does not show whether code ran; the console marker does.

For a UI failure check, load the page, stop the local server, and press **Check the API**. The error should leave the button usable. Restart the server and press it again.

## What happens at the Free limits?

The daily execution quota resets at **midnight UTC**. For requests covered by `run_worker_first`, Cloudflare documents **HTTP 429** when the Free request limit is exceeded, rather than falling back to an asset. The [general limits page](https://developers.cloudflare.com/workers/platform/limits/) also documents quota and CPU error conditions; the exact behavior depends on the route, so do not treat every error as the same status code.

Serving the page without invoking code keeps those asset requests out of the execution meter. It does not reserve capacity for the endpoint. Other Workers in the account and unwanted traffic can use the same daily pool. The client still needs the failure state above.

CPU time is time spent executing the code. Waiting for a network response is different. A handler that waits a second on another service has not necessarily used a second of CPU, while parsing a large payload or rendering a complicated page can consume the short CPU budget without much waiting. Local elapsed time cannot establish hosted CPU usage.

This example adds no storage, model inference or other billable service. Those products have separate limits. Before adapting it into a larger project, check your account plan and the limits of each product you add; the [maintained free-compute guide](/free-compute/) is the wider comparison. For a site that only needs static files, remove the API and the Worker script entirely. There is no reason to spend the execution allowance on work the browser or asset service already does.
