Engineering noteResearch & Engineering
Part of: Cloudflare Workers
Counting to 128 Before We Add One Worker Secret
We count built variables before a deploy and deployed variables before a secret write, adding the live secret inventory in both cases.
The programme
On this page
On 27 September 2026 we could not add one API key to production TEEI Language. wrangler secret put came back with error 10055: “Too many text bindings, found a total of 129, they exceed the limit of 128”. The Worker already carried 34 plain-text variables and 94 secrets, the full allowance of 128, and nothing in our build or tests had read what was live on it.
We now count before writing. The inputs depend on the write: the built configuration’s variables before a code deploy, the deployed version’s variables before a standalone secret write, and in both cases the live secret names plus any new ones. If we remove a variable from a candidate, we free no slot until we deploy that candidate.
We build TEEI Language and TEEI Mentorship from one React Router 8 codebase and deploy them as two Workers. Each Worker has its own configuration, so each has its own count against the 128. How TEEI runs its platforms on Cloudflare Workers covers the other limits we met in 2026.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
What Cloudflare counts, and when
As of 15 October 2026, Cloudflare’s limits page lists “Variables per Worker (secrets + text)” as 64 on Workers Free and 128 on Workers Paid, with each variable at most 5 KB. The two kinds share one count because, in the words of the secrets documentation, “Secrets are environment variables”. Crossing the limit is error 10055.
- It is checked at the write, not at the build. We do not see the live secret inventory in a build or a test unless we fetch it. Before we added our count, a change that added one variable could pass every local check and still exceed the combined limit at upload.
wrangler secret putdeploys immediately. Per the documentation, it “creates a new version of the Worker and deploys it immediately”. Both refusals we have on record came from secret writes, not from code deploys: thesecret puton 27 September, and awrangler secret bulkon 12 October.- A deploy keeps the secrets and replaces the variables. The Wrangler command reference says “Secrets are never deleted by a deployment”, and that Wrangler overrides variables changed in the dashboard at the next deploy unless
keep-varsis set. Before a code deploy, we count built variables and live secret names. In our React Router 8 build we choose the Wrangler environment before compiling, and the build flattens that choice into the upload artefact, so we read the variables from that artefact.
One function that counts first
We fixed 27 September in two halves. With approval, we deleted two secrets that the Worker code did not read, set the key, and stood at 34 variables and 93 secrets, 127 of 128. Then we wrote the count down as code, so that the next person asks before writing instead of finding out from Cloudflare.
Adapted for publication.
export const TEXT_BINDING_LIMIT = 128;
/** Below this many free slots the verdict is LOW. */
export const HEADROOM = 3;
/**
* @param {{ vars: string[], secretNames: string[], plannedSecrets?: string[], limit?: number, headroom?: number }} input
* `plannedSecrets` are secrets about to be put; one already on the Worker is not counted twice.
*/
export function bindingBudget({ vars, secretNames, plannedSecrets = [], limit = TEXT_BINDING_LIMIT, headroom = HEADROOM }) {
const varNames = new Set(vars);
const secrets = new Set(secretNames);
const added = plannedSecrets.filter((name) => !secrets.has(name));
for (const name of added) secrets.add(name);
// A name that is both a var and a secret cannot be uploaded at all, so it is reported by name
// rather than folded into the arithmetic.
const collisions = [...secrets].filter((name) => varNames.has(name)).sort();
const total = varNames.size + secrets.size;
const free = limit - total;
const verdict = collisions.length > 0 || total > limit ? 'OVER' : free < headroom ? 'LOW' : 'PASS';
return { vars: varNames.size, secrets: secrets.size, added, collisions, total, limit, free, verdict };
}
Three details carry most of the value. Sets, not arrays, so a planned secret that is already on the Worker is an update and costs nothing. A name that is both a variable and a secret is reported as OVER whatever the count, because it cannot be uploaded at all. And exactly 128 is LOW, not OVER: it uploads, and the next binding does not. LOW covers zero, one and two free slots, so it is a warning, not a promise that one more change will fail.
Before a code deploy, we read the target’s built variables and live secret names, never secret values, and we can pass a planned secret to ask “can I set this?”. The answer is only as good as the variables we count. A standalone secret put or secret bulk uploads no variables, so before one of those the variables that count are the ones in the deployed version. On 12 October our Language Worker carried 31 variables while the candidate we were preparing carried 30; until we deployed the candidate, the variable it dropped freed nothing.
The count also reserves nothing. It records the inventory at the moment we read it, another write can take a slot before ours, and Cloudflare still makes the final decision. We made the command exit 0 for PASS or LOW and 1 for OVER. When we cannot read the live names it exits 3, INCONCLUSIVE, and we wrote down plainly what that means: nothing measured, never a pass.
In the unit tests, we replay the refusal: 34 variables, 94 secrets and one planned key give 129, OVER. In our live check on 27 September 2026, we counted production Language at 127 of 128, LOW, and Mentorship at 85, PASS; two planned secrets gave 129 and OVER, and no credentials gave INCONCLUSIVE.
The gates in front of Cloudflare’s
We do not rely on someone remembering to run the script.
A ceiling in a test. We cannot read secrets from a file, so in a contract test we cap the production Language variables and check the cap against the secret count we measured on the same day.
Adapted for publication.
const PRODUCTION_VAR_CEILING = 25;
const SECRETS_MEASURED_2026_10_12 = 97;
describe('production Language vars against the 128 bindings', () => {
it('declares no more vars than the ceiling', () => {
expect(Object.keys(LANGUAGE.vars).length).toBeLessThanOrEqual(PRODUCTION_VAR_CEILING);
});
it('keeps the ceiling itself under Cloudflare\'s limit beside the secrets measured with it', () => {
expect(PRODUCTION_VAR_CEILING + SECRETS_MEASURED_2026_10_12).toBeLessThan(TEXT_BINDING_LIMIT);
});
});
Three more tests in the same file fail if one of seven database-backed flags, a host name or one other setting comes back into the production variables. We log each ceiling change with its date and measurement, and the rule next to the constant is that raising it is a decision taken after reading the live total, never a fix to make a red test green. The test has a limit of its own: the 97 is a number we typed in. It matched the live count on 15 October; we cannot detect later secret-count changes through this test. For that we run the script.
A test that pins defaults. When we remove a variable because the code that reads it already falls back to the same value, we test that each reader’s default matches the value the configuration used to declare, across the relevant blocks of all three Worker configurations. If someone changes a reader’s default later, we catch it in the test.
The break-glass lane. In our release lane for urgent fixes, we run the same count against the built configuration of every target and refuse to mark a candidate ready on OVER or INCONCLUSIVE.
Four ways to free a slot
Asking for more room was never on the table, because 128 is the Paid limit. Every slot we needed after 27 September came from the same Worker.
- Move a flag that is off to the database-backed flag store. A Wrangler variable lives in the Worker version, so changing it means a deploy. A row in a flag table can be changed without one and costs no binding. Our normal order: the catalogue that names the flag as a database flag ships first, then we write a row per programme with today’s value, and only the release after that removes the variable. For three flags that were off on 27 September we skipped the rows, because their readers treat a missing value as off. Turning a database flag on still goes through the same approval as it did when it was a variable.
- Delete a secret nothing reads. That gave us the two slots on 27 September. By 14 October that well was dry: we checked, and none of the 97 live secrets was unread.
- Let the reader carry the default. On 12 and 13 October an analytics host and a log level left the production variables, because the code that reads them already defaulted to exactly the declared values. The defaults test pins that.
- Fold related values into one JSON secret. On 13 October we changed the code that configures our story-card renderer to read several related settings from one secret holding a JSON object, so on Language they need one binding instead of several. Writing that secret on Language was among the planned slots, not a measured change.
The fold needs one rule. We treat a missing or blank folded value as absent, and only then does the renderer look for the old separate settings. A non-empty value we cannot parse turns rendering off instead of falling back, so a broken secret cannot quietly run the renderer on stale values. The cost: one typo in that secret takes the renderer, and everything that relies only on that value, dark.
The flag store needs two guards to keep it from becoming a second way to break things. We take a value from the table only for a key that the bundled flag catalogue lists as a database flag, and the staff page refuses to write any other. And the three switches that decide whether our scheduled jobs run never come from the database, because, as the module puts it, “a database that is down must not be able to change whether the crons run”. The rows also have a scope. We lay them over the Worker’s environment for application requests that reach the database-backed application and for scheduled jobs dispatched through our application router.
Three weeks on production Language
Figure 2 shows the free slots at the points we measured: none at the refusal on 27 September, one after the fix, and none again on 7 and 12 October. On 12 October a secret bulk for search settings was refused with 10055 and nothing was written; that evening we removed three variables from the production configuration.
On 15 October 2026 we counted 25 variables in the committed production Language configuration and 97 secret names live from Cloudflare, 122 of 128; Mentorship gave 26 and 72, 98 of 128, by the same method. These are committed configurations plus live secrets, not inventories read from the deployed versions, and we did not count the staging, preview and demo Workers. Several of our commits from 12 to 14 October describe Language as “128 of 128”; some of those totals included planned additions rather than settings already written, so they are not measurements of the deployed inventory.
Trade-offs
Database flags cost a read. Each isolate caches the flag rows briefly and refreshes them only when a caller asks; where the request allows, we serve the expired snapshot at once and refresh behind the response. So a changed flag has no guaranteed time to reach every isolate. If you need a flag to change everywhere within a known time, a variable and a deploy is the more predictable tool.
We test a shared read that never settles: we remove its creator’s timers, then start a second caller, and require that caller to return no flag rows at its own deadline, with one database SELECT in total.
A lower ceiling adds friction. Every time we lowered the ceiling in the test, we removed the option of “just add a variable” for the next person. That is the point, and it is also friction: work that needs a binding has to retire one first.
When this is the wrong pattern. For a Worker far below its binding limit, running the count before writes may be enough. We use ceilings and change logs because production Language repeatedly reached the binding limit between 27 September and 12 October 2026 while several people were adding to it.
Verify this
For a code deploy, count variables in the selected environment’s vars block, or the generated configuration if your framework builds one. For a standalone wrangler secret put or secret bulk, use the deployed version’s variables. Run npx wrangler secret list for that same Worker and environment. Merge its secret names with those you plan to write, counting existing names once; an update adds no slot. Add that count to the variables and compare with 128 (64 on Free). If a name is both a variable and a secret, resolve the collision even when the total fits.
Our own counts for TEEI’s Workers come from internal measurements and configuration, so you cannot check them from outside; we have given each one with its method and date.
Frequently asked questions
How many environment variables and secrets can a Cloudflare Worker have?
Cloudflare counts plain-text variables and secrets against one limit per Worker: 64 on Workers Free and 128 on Workers Paid. A write that would cross it is refused with error 10055.
When does Cloudflare check the 128 limit?
When something is written to the Worker: a deploy, a wrangler secret put or a wrangler secret bulk. We do not read the live secret inventory during the build, so a change can build and still be refused at upload.
How can I free a slot when a Worker is at 128?
We use four ways: move a feature flag that is off to a database-backed flag store, delete a secret nothing reads, let the code carry a default and remove the variable, or fold several related values into one secret that holds JSON.
How do I count my own Worker against the limit?
Before a code deploy, count variables in the configuration you will upload; before a standalone secret write, use the deployed version. For the same Worker, combine live secret names from npx wrangler secret list with planned names, counting updates once. Add the variables and compare with 64 or 128.