Workers AI comes with a free allowance that's easy to like: 10,000 neurons a day, on the free Workers plan and on Workers Paid. Perron, our train-station chat app, uses it to review every post with Cloudflare's Clef model, and on an ordinary day the allowance covers all of it.
We wanted that to stay true. "The allowance covers it" isn't the same as "it can never cost money", though, and the gap between those two is what this post is about. It comes down to two details of how the allowance works, one of which only bites once you've upgraded.
What the allowance actually is
Cloudflare's Workers AI pricing page is short and worth reading in full. The parts that matter here: every account gets 10,000 neurons a day, on both plans, and the count resets at 00:00 UTC. On Workers Paid, anything above the allowance costs $0.011 per thousand neurons. On Workers Free there's no paid rate, so once the neurons are gone, AI requests fail until the reset.
A neuron is Cloudflare's unit for AI compute. You never buy neurons directly; each model has a published rate in neurons per million input tokens and per million output tokens, and your usage is converted. Clef, the model we use most, costs 21,818 neurons per million input tokens and nothing for output, which is about 0.0218 neurons per token.
That conversion matters because almost every model reports its usage in tokens, not neurons. If you want to know what a call cost, you have to do the multiplication yourself.
The first catch: on Paid, over budget means billed
On the free plan, the allowance is a wall. You can't overspend it, because the platform refuses. That's frustrating at the wrong moment but it's also a perfect budget control.
Perron moved to Workers Paid on 2026-10-15 so it could use Queues. On that day the wall turned into a meter. Going over the daily allowance no longer fails, it just starts billing. Nothing in the platform would stop a busy evening, or a bug that called the model in a loop, from turning into a line on the invoice.
At $0.011 per thousand neurons that's not going to bankrupt anyone. But the whole point of Perron's design is that it's a free app that costs us nearly nothing to run, and "nearly nothing, unless something goes wrong" isn't the same promise. We wanted spending money to be a decision someone made, not a side effect.
The second catch: the allowance belongs to the account
This one applies on both plans and it's the one that shapes the code. The 10,000 neurons aren't given to a Worker. They're given to the account, and every Worker on the account draws from the same pool.
We run a lot of Workers on one account: Perron, its staging copy, an OTA server, a handful of game backends, several static sites. Today Perron is the main user of Workers AI, but that's not something a Worker can assume. A Worker that only counts its own calls has no idea whether some other project on the account already burned half of today's budget at breakfast.
So a Worker that wants to stay inside the free allowance has to be able to answer one question before each AI call: how many neurons has the whole account used today?
Asking Cloudflare: the GraphQL Analytics API
Cloudflare's GraphQL Analytics API reports Workers AI usage per account, including neurons, in a dataset called aiInferenceAdaptiveGroups. One query gives you the total for a UTC day:
async function accountNeuronsToday(env: Env, day: string): Promise<number | null> {
const res = await fetch("https://api.cloudflare.com/client/v4/graphql", {
method: "POST",
headers: {
authorization: `Bearer ${env.CF_ANALYTICS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
query: `query($a: String!, $d: Date!) {
viewer { accounts(filter: { accountTag: $a }) {
aiInferenceAdaptiveGroups(limit: 200, filter: { date: $d }) { sum { totalNeurons } }
} }
}`,
variables: { a: env.CF_ACCOUNT_ID, d: day }, // day = "2026-10-21", in UTC
}),
signal: AbortSignal.timeout(10_000),
});
const body = await res.json();
if (!res.ok || body.errors?.length) return null;
const groups = body.data?.viewer?.accounts?.[0]?.aiInferenceAdaptiveGroups ?? [];
return groups.reduce((n, g) => n + (Number(g.sum?.totalNeurons) || 0), 0);
}
A few details in there are deliberate. The date is a UTC date, because that's when Cloudflare resets the allowance; if you build "today" from local time in Amsterdam you'll be off by one or two hours twice a year. The function returns null on any failure instead of zero, because "I don't know" and "nothing used" are very different answers for a budget. And the fetch has a timeout, because a budget check that can hang will eventually hang a moderation batch with it.
The token needs one permission: read access to account analytics. Nothing more. It's stored as a Worker secret, and if it ever leaked, the worst anyone could do with it is look at our graphs.
Analytics lag, so keep your own tally too
GraphQL analytics aren't real time. A call you made a minute ago might not show up in the total yet. And even if they were real time, querying the API before every model call would be silly: it'd add latency to every batch and pile up API requests for no reason.
So the meter combines two numbers. The first is Cloudflare's count, read at most every ten minutes. The second is our own tally of neurons spent since that read. Clef reports input tokens with every response, and we multiply by the published rate; for models that report neurons directly, we use those.
Ten minutes is a judgement call. Shorter means fresher numbers and more API calls; longer means we lean more on our own tally, which only knows about our own spending. For an allowance that resets daily and a project that's the main consumer, ten minutes has been comfortable.
The meter lives in a Durable Object, the same one that runs the moderation queue. That gives us exactly one copy of the meter for the whole app, with state that survives between requests, and it means every batch checks the same numbers. Twenty isolates around the world each keeping their own tally would undercount badly.
const day = new Date(now).toISOString().slice(0, 10);
let m = (await this.ctx.storage.get<AiMeter>("aiMeter")) ?? null;
if (!m || m.day !== day) m = { day, account: 0, readAt: 0, sinceRead: 0 };
if (now - m.readAt >= 10 * 60_000) {
const account = await accountNeuronsToday(this.env, day);
m = account !== null
? { ...m, account, readAt: now, sinceRead: 0 }
: { ...m, readAt: now }; // keep the old number, try again later
await this.ctx.storage.put("aiMeter", m);
}
When the UTC date changes, the meter starts a fresh day, which happens at the same moment Cloudflare resets the allowance. If GraphQL can't be read, the meter keeps its last known account number plus its own tally since then, and tries again at the next refresh. It never assumes the account has used nothing.
The check before every batch
Before a batch goes to Workers AI, we estimate what it'll cost and compare:
Cloudflare's count + our tally since that read + estimate for this batch <= budget
The estimate is easy for Clef, because cost is just input tokens. A batch of forty posts with the policy text comes to roughly 124 tokens per post, and we have the text in hand, so the estimate is close.
The budget isn't 10,000. It's 9,000, stored in runtime config so we can change it without a deploy. The 10% margin covers analytics lag, the gap between our estimate and the real token count, and any other Worker on the account that started spending since our last read. You could argue for a smaller margin. We'd rather leave a few hundred free neurons on the table every day than pay for them.
When the check fails, the batch goes to the next provider in the chain. For Perron that's a guard model on a Mac we own, reached through Cloudflare Tunnel with an Access service token. If the Mac is down too, the batch stays queued and retries later. Posts stay up while they wait, because Perron publishes first and reviews after, so a budget that runs out at 21:00 means slower reviews, not a broken app.
There's one more provider in the chain, and it's off: paid Workers AI. A single runtime flag turns it on. If we ever have a day where both the free neurons and the Mac are gone and we decide we'd rather pay than wait, we'll flip it, and that'll be a choice somebody made, with a timestamp in our config audit log.
How far 10,000 neurons go
The honest answer is that it depends almost entirely on which model you call and how you call it.
Before we moved to Clef, we benchmarked general models on Workers AI with the same batch of fifty hard posts. Llama 3.1 8B used 13 neurons for the batch. gpt-oss-20b used 71. Llama 3.3 70B used 96. That's a 7x spread for the same work, and the cheapest one was also the one that missed a death threat, so cheap and good weren't the same axis.
With Clef in batches of forty, a post costs about 124 input tokens, which is roughly 2.7 neurons. The free allowance covers about 3,700 posts a day. Called one post at a time, Clef used 276 tokens per post, more than twice as much, because the policy text is sent again with every call. Batching is by far the biggest lever we found, bigger than choosing a smaller model, and it made the results more accurate as well.
If your use isn't moderation the same idea still applies. Look at what's repeated in every call (a system prompt, a policy, few-shot examples) and see if you can pay for it once per batch rather than once per item.
The same pattern for the rest of the account
Once we'd built the neuron meter, we noticed we had the same problem in other places. The free plan's daily limits are account-wide too: Durable Object requests, SQLite rows read and written, Worker requests. On the free plan they're a cliff. Go over at 15:00 and the app stops answering until midnight UTC.
Perron has a small quota alarm that runs on the five-minute cron. It reads the account's usage from the same GraphQL API, using datasets such as durableObjectsInvocationsAdaptiveGroups, and compares it to the daily limits. At 50%, 75% and 90% it sends an email, once per threshold per day. The "already sent" state is a KV key per UTC day, so it survives restarts without needing a database. The 50% mail is rarely urgent. It's there so that the 90% one is never a surprise.
Some things don't appear in account analytics at all, or not per request. Analytics Engine writes are one example, and Cloudflare's Cache API is another, since cache reads aren't metered. For those, Perron keeps its own counters in a Durable Object and shows them on the admin services page next to the numbers from GraphQL. It's a bit odd to meter something Cloudflare doesn't charge for. But when you're trying to understand where your requests go, the things that aren't on the bill are exactly the ones you can't see anywhere else.
A short list, if you're doing this yourself
Treat the allowance as an account budget, because that's what it is. Read the account total from aiInferenceAdaptiveGroups and add your own tally since the last read. Do your date maths in UTC. Leave a margin below 10,000. Decide in advance what happens when the budget's spent: a fallback, a queue, or a deliberate switch to paid. And batch, so the expensive part of the prompt is paid for once.
None of this is complicated. Most of it is the kind of code you write once and forget about. That's sort of the point: the day you upgrade to Workers Paid is the day the platform stops guarding the budget for you, so something else has to.
The rest of the free-plan numbers we design around are in Cloudflare Workers free plan limits.