Home /
Cloudflare Workers free tier: keep static files out of the API budget
Workers Free allows 100,000 execution requests a day; static asset requests are free. Build a site and API with routing checked locally.
- Static asset requests and Worker execution have separate accounting.
- Selective API routing keeps matching static files out of the handler.
- Local checks do not establish hosted CPU usage or quota behavior.
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 and 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. 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:
site-and-api/
package.json
wrangler.jsonc
src/
index.js
public/
index.html
app.js
styles.css
Use this package.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:
{
"$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 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:
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:
<!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:
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:
: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:
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:
{"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.
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 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 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.