Cloudflare Workers Cost Optimization: A Practical Guide
I've spent the last year helping teams reduce their Cloudflare Workers bills while keeping performance high. This guide covers what actually works in production, complete with code you can copy and adapt.
1. Understanding What You Pay For
Before we write any code, let's talk about the billing model. Cloudflare charges for four things that matter to most developers.
- Worker invocations count every request that hits your Worker. Even if your Worker does nothing but return 'hello world', that's one invocation.
- CPU time gets measured in gigabyte seconds. This represents how long your Worker runs and how much memory it uses. A Worker that processes images for 500 milliseconds costs more than one that returns a cached response in 2 milliseconds.
- Bandwidth costs come from data moving through your Worker. If you're proxying large files, this adds up fast. The same applies to origin egress when your Worker fetches from your backend or R2.
- Storage operations include KV reads and writes, R2 requests, and Durable Object transactions. These are usually cheaper than compute but still worth watching.
Your goal boils down to three things. Hit cache as often as possible. Keep Workers active for the shortest possible time. Never use Workers as a dumb proxy for big files.
2. Starting Simple
Let's begin with a basic Worker and optimize it step by step. Here's what most people start with.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/api/data") {
return handleApi(request, env);
}
return new Response("Hello from Cloudflare Workers!", {
headers: { "content-type": "text/plain" },
});
},
};
async function handleApi(request, env) {
const data = { message: "Hello, world", time: Date.now() };
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
});
}
This works but every API request runs your Worker and hits whatever logic you've defined. That gets expensive fast.
3. Caching API Responses at the Edge
The Cache API is your first line of defense. It stores responses at Cloudflare's edge locations so repeated requests don't run your Worker code.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/api/data") {
return cacheApiResponse(request, env, ctx);
}
return new Response("Hello from Cloudflare Workers!", {
headers: { "content-type": "text/plain" },
});
},
};
async function cacheApiResponse(request, env, ctx) {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
if (response) {
return response;
}
const data = { message: "Hello, world", time: Date.now() };
response = new Response(JSON.stringify(data), {
headers: {
"content-type": "application/json",
"Cache-Control": "public, max-age=60",
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
The important detail here is ctx.waitUntil. It lets the cache write happen asynchronously without blocking the response. Your Worker becomes idle immediately after returning the cached response, which lowers your CPU time bill.
4. Stop Running Workers for Static Files
Many developers accidentally route all traffic through their Worker. This means every image, CSS file, and JavaScript bundle triggers an invocation. You can fix this in the Cloudflare dashboard.
Go to Workers and then Routes. Set up your main route as example.com/*. Then add bypass rules for static assets. For example, create a rule that skips the Worker for example.com/assets/* and example.com/images/*.
You can also combine this with Cache Rules. Set assets to cache for longer periods with no Worker involvement. This single change often cuts invocation counts by 70 percent.
5. Streaming and Idle Workers
Cloudflare now charges less once your Worker becomes idle. This happens after you send response headers. You can take advantage of this by streaming large responses.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/big-file") {
return streamBigFile(request, env);
}
return new Response("OK");
},
};
async function streamBigFile(request, env) {
const originResponse = await fetch("https://example.com/large-file.bin");
return new Response(originResponse.body, {
headers: {
"content-type": "application/octet-stream",
"Cache-Control": "public, max-age=3600",
},
});
}
The Worker waits for the origin to start sending data, then immediately sends headers and becomes idle. The file continues streaming through Cloudflare's network without keeping your Worker busy.
That said, I still recommend avoiding this pattern for very large files. Use presigned URLs instead.
6. Presigned URLs with R2
This approach eliminates Workers from the file delivery path entirely. Your Worker generates a temporary URL, and the client downloads directly from R2.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/download") {
return generatePresignedUrl(request, env);
}
return new Response("Not found", { status: 404 });
},
};
async function generatePresignedUrl(request, env) {
const url = new URL(request.url);
const key = url.searchParams.get("file") || "example.bin";
const expiresInSeconds = 300;
const base = `https://your-r2-public-domain/${encodeURIComponent(key)}`;
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
const signature = await createSignature(base, expiresAt, env.SECRET);
const presignedUrl = `${base}?expires=${expiresAt}&sig=${signature}`;
return new Response(JSON.stringify({ url: presignedUrl }), {
headers: { "content-type": "application/json" },
});
}
async function createSignature(base, expiresAt, secret) {
const encoder = new TextEncoder();
const data = encoder.encode(`${base}:${expiresAt}`);
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signatureBuffer = await crypto.subtle.sign("HMAC", key, data);
const bytes = new Uint8Array(signatureBuffer);
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
The Worker does minimal crypto work to generate the URL, then gets out of the way. The client downloads directly from R2. No bandwidth passes through your Worker. No CPU time spent streaming.
7. Using HTMLRewriter for Edge Changes
Instead of modifying HTML at your origin, you can make changes at the edge. This saves origin CPU and bandwidth while keeping your Worker lightweight.
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
return new HTMLRewriter()
.on("body", new BannerInjector())
.transform(response);
},
};
class BannerInjector {
element(element) {
element.prepend(
`<div style="background:#fffae6;padding:8px;text-align:center;">
Student-friendly Cloudflare setup – cached at the edge
</div>`,
{ html: true }
);
}
}
The HTMLRewriter parses the response stream without buffering the entire document. It modifies the HTML as it passes through, then sends it to the client. Your Worker stays efficient and your origin serves plain HTML without templating overhead.
8. Short Lived Caching for Dynamic Data
Even APIs that change frequently can benefit from caching. A five second cache on a popular endpoint might reduce origin calls from thousands per minute to a few hundred.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/stats") {
return cachedStats(request, env, ctx);
}
return new Response("OK");
},
};
async function cachedStats(request, env, ctx) {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
if (response) return response;
const stats = {
usersOnline: Math.floor(Math.random() * 100),
timestamp: Date.now(),
};
response = new Response(JSON.stringify(stats), {
headers: {
"content-type": "application/json",
"Cache-Control": "public, max-age=10",
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
A ten second cache on a stats endpoint turns one thousand origin calls into one hundred. The numbers add up quickly.
9. Adding Observability
You cannot optimize what you do not measure. Add simple logging to understand your traffic patterns.
export default {
async fetch(request, env, ctx) {
const start = Date.now();
const response = await handle(request, env, ctx);
const duration = Date.now() - start;
ctx.waitUntil(
fetch(env.LOG_ENDPOINT, {
method: "POST",
body: JSON.stringify({
path: new URL(request.url).pathname,
duration,
status: response.status,
}),
headers: { "content-type": "application/json" },
})
);
return response;
},
};
async function handle(request, env, ctx) {
return new Response("OK");
}
Review this data regularly. Look for endpoints with high invocation counts and low cache hit ratios. These are your optimization opportunities.
10. A Complete Cost Aware Worker
Here is a pattern that combines everything we have covered.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith("/files/")) {
return generatePresignedUrl(request, env);
}
if (url.pathname === "/stats") {
return cachedStats(request, env, ctx);
}
const originResponse = await fetch(request);
if (originResponse.headers.get("content-type")?.includes("text/html")) {
return new HTMLRewriter()
.on("body", new BannerInjector())
.transform(originResponse);
}
return originResponse;
},
};
This Worker avoids streaming big files by generating presigned URLs. It caches expensive stats endpoints. It uses HTMLRewriter for edge tweaks. And static assets never reach your Worker because of route rules, functioning much like a precision utility station for your traffic.
Conclusion
Start with caching. It is the easiest win and usually gives the biggest return. Then look at your route configuration to ensure static assets bypass your Worker. After that, examine any large file transfers and consider presigned URLs.
Monitor your dashboard and logs regularly. New endpoints get added, traffic patterns change, and what worked last month might need adjustment today. The tools Cloudflare provides are powerful, and with a bit of thought, you can keep your costs low while maintaining great performance for your users.