When to Use Cloudflare Workers — Zero-Backend Server Logic and a CORS Proxy
TL;DR: This site — the blog and the tools — runs on zero backend servers. No EC2, no always-on box, no deploy pipeline. Pure static frontend. And yet it measures accurate traffic and pulls data from third-party APIs that block CORS. Building frontend-only, you hit exactly two walls — a full server is overkill, but the browser alone can't — and I wedge a Cloudflare Worker into both. This is when a Worker is the right tool, with code.
So what is a Cloudflare Worker
In one line: a function that runs at the edge, before the request reaches your server. You don't keep a server running (it executes only on request), deploys are one command, and the free tier alone covers 100k requests a day. Need data? Attach D1 (Cloudflare's SQLite).
It fits precisely here:
The middle ground where a full backend is overkill, but the frontend alone can't do it.
When you want a bit of logic in front of the request — no provisioning, no always-on box, no deploy infra.
Below are the two cases I hit most often building frontend-only.
Wall ① Frontend-only can't measure traffic accurately
Some things a static frontend just can't do — accurate visit analytics is the classic one. Browser-side analytics (GA4, GoatCounter) lose a big chunk to ad blockers, privacy browsers, and bots. You need to measure server-side to be accurate — but standing up a backend just for stats is the tail wagging the dog.
That's the Worker's spot. Intercept the request at the edge and log it to D1, and you get server-side analytics with no backend server. That's exactly what runs on this site.
The key is responding first and logging afterward, asynchronously (ctx.waitUntil) — the visitor doesn't feel a millisecond of it. There's a lot of detail here — fail-open so a logging bug can't take the site down, cookieless daily hashing so you count visitors without storing personal data, bot filtering by network, a cron rollup, and locking the dashboard so nobody else can open it.
The lesson from case ① wasn't a trick — it was asking whether I actually needed the fancy tool. Cloudflare has Workflows (durable execution); I considered it for the rollup and didn't use it, because it changes how something runs, not how much it aggregates. "There's a shiny tool, let's use it" is the wrong reflex; "does this problem need it?" is the right one.
Wall ② The browser blocks a third-party API with CORS
Fetching an external API or resource from the frontend, you hit this wall:
Access to fetch at 'https://api.foo.com/...' from origin 'https://mysite.com'
has been blocked by CORS policy
CORS means the response is only readable from another origin's browser if the server adds a permission header (Access-Control-Allow-Origin). The catch: whether that header gets added is the other server's call. If they don't add it, the browser gives you no way through.
If it's your API — one header
If that API is your own Worker, it's trivial: add one header to the response. This site's stats dashboard calls its stats API from another origin, and the Worker opens it up like this:
// your stats API response
headers: {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*', // ← this one line is the CORS grant
}
If it's someone else's API — stand up a proxy Worker
The real problem is an API you can't change. Here you put a Worker in the middle as a proxy: browser → (your Worker) → their API, and the Worker adds CORS headers to the response on the way back. Server-to-server calls have no CORS, so it goes through.
This is where most tutorials teach something dangerous. Make it an "open proxy" that fetches any URL, and you've just built a free relay for the whole internet plus an SSRF target. So I lock it down — allowlist of hosts + GET only + restrict who can call it + strip cookies:
const ALLOW_HOSTS = ['api.foo.com']; // only hosts you'll proxy for
const ALLOW_ORIGINS = ['https://mysite.com']; // only your site may call this proxy
export default {
async fetch(request) {
const origin = pickOrigin(request);
if (request.method === 'OPTIONS') // preflight
return new Response(null, { status: 204, headers: corsHeaders(origin) });
if (request.method !== 'GET' && request.method !== 'HEAD')
return json({ error: 'method not allowed' }, 405, origin);
const t = new URL(new URL(request.url).searchParams.get('url'));
if (t.protocol !== 'https:') return json({ error: 'https only' }, 400, origin);
if (!ALLOW_HOSTS.includes(t.hostname)) return json({ error: 'host not allowed' }, 403, origin);
const upstream = await fetch(t.toString(), { // no cookies/auth forwarded
headers: { accept: request.headers.get('accept') || '*/*' },
cf: { cacheTtl: 300, cacheEverything: true }, // 5-min edge cache
});
const headers = new Headers(upstream.headers);
Object.entries(corsHeaders(origin)).forEach(([k, v]) => headers.set(k, v));
headers.delete('set-cookie'); // block cookie leakage
return new Response(upstream.body, { status: upstream.status, headers });
},
};
From the frontend you call it like this:
// what used to be blocked by CORS
fetch('https://api.foo.com/data.json')
// now goes through your proxy
fetch('https://cors.mysite.com/?url=' + encodeURIComponent('https://api.foo.com/data.json'))
Summary — when a Worker, when a real backend
The instinct that runs through both cases:
| When you have… | Reach for |
|---|---|
| Persistent state, DB transactions, complex domain logic | A real backend server |
| Light server-side logic in front of the request (logging, aggregation, auth checks, redirects) | A Worker |
| A third-party API that won't send CORS | A Worker proxy (allowlist) |
| Something the frontend can do on its own | Just the frontend |
A Worker isn't a silver bullet. Heavy state or long transactions still want a real backend. But for that awkward middle — overkill for a full server, impossible for the frontend alone — one stat, one CORS wall — nothing beats it.
If you run a static site and a moment comes where you think "do I really have to stand up a server for this?" — remember Workers. It's less code than you'd expect.
Comments