Engineering noteResearch & Engineering
Part of: Cloudflare Workers
What We Bypass with Cloudflare Service Bindings
We moved a failing call onto a service binding and it kept failing. Weeks later we found that our RPC calls could write through a maintenance pause.
The programme
On this page
- Three ways our Workers reach each other
- A guard in fetch never sees an RPC call
- TEEI Language to Mentorship: a fetch binding into the same router
- 2 September 2026: the binding was not the fix
- TEEI Language to Evenhall: a named entrypoint
- The binding was in the file, not in the Worker
- Binding in staging, public fetch in production
- Why our monitoring takes the public road
- What a binding costs, and what we have not measured
- Verify this
- Frequently asked questions
Evenhall has a maintenance switch that pauses writes while we work on its database. We checked it in the Worker’s fetch handler. Until 10 October 2026, our two RPC entrypoints into Evenhall went around that check: in the words of the commit that closed the gap, they “never pass the fetch check, so they wrote while” the switch was on. We now check the switch inside every writing RPC method.
That is the difference this note is about. When we connect Workers through a fetch binding, we keep the receiving router and every check in it. When we use RPC, we call methods directly. And through either kind of service binding, we skip the public edge, so we use public URLs for health checks: there we test the route a visitor takes.
Our overview of how TEEI runs its platforms on Cloudflare Workers introduces these connections. Here we go through the three paths between our Workers, and through incidents between 2 September and 10 October 2026 that taught us what a binding changes and what it does not. We checked Cloudflare’s documentation on 15 October 2026; our own figures carry their own dates.
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.
Three ways our Workers reach each other
Public fetch. We send an ordinary fetch() from the calling Worker to the other Worker’s public hostname. The request goes out through Cloudflare’s edge and can be answered there before it reaches the target’s fetch handler. On the same zone this only works in some cases: a global fetch to a Worker served through a route or a workers.dev subdomain is refused with error 1042 unless the caller has the global_fetch_strictly_public compatibility flag, while a Worker on a Custom Domain accepts it.
Fetch binding. We declare a service binding without an entrypoint in the calling Worker and call env.BINDING.fetch(request). Cloudflare’s Wrangler configuration reference says that “if you do not specify an entrypoint, the default export of the Worker will be used”, so the request lands in the same fetch handler that public traffic reaches, with the same router and the same middleware. What it skips is the public edge: service bindings, in Cloudflare’s words, “allow one Worker to call into another, without going through a publicly-accessible URL”.
RPC binding. We export a class that extends WorkerEntrypoint from the receiving Worker, name that class as the entrypoint of a binding in the calling Worker, and call its methods as if they were local: await env.BINDING.someMethod(arg). Cloudflare’s service bindings page calls this form “recommended for most use cases”. The RPC documentation adds that “a new instance of the class is created every time the Worker is called”. There is no Request, no status code and no fetch handler on this path.
From TEEI Language we reach the Mentorship Worker through a fetch binding and Evenhall through an RPC entrypoint. We expose a second Evenhall entrypoint to the admin our staff use to review company applications. We run Evenhall’s scheduled jobs from a small scheduler Worker that reaches Evenhall through a fetch binding. From admin we reach TEEI Language and TEEI Mentorship in production with a public fetch, on purpose, and we come back to why.
A guard in fetch never sees an RPC call
We enforced Evenhall’s maintenance switch in the fetch handler, before handling HTTP writes, including those that arrive through a fetch binding. TEEI Language and our admin reach Evenhall through two RPC entrypoints, and an RPC call never enters the fetch handler. So while the switch paused HTTP writes, the RPC writes went through.
We moved the check into the methods. In every writing method, we check the switch first and return a write_paused result, the same word the HTTP refusal carries, before we open a database client. In the admin entrypoint, a small wrapper runs that check before each writing method. We leave the reading methods unchanged.
The lesson is broader than one switch. Anything you put in a Worker’s fetch handler, whether authentication, rate limits, logging, a request ID or a maintenance mode, covers public traffic and fetch bindings, and nothing that arrives over RPC. If your guards live there, a fetch binding keeps them; with RPC, you move each check into the methods.
In the regression test, we call every writing method with the switch on and expect write_paused, with neither the domain operation nor the database client called; we check that the reading methods still run, and that writes go through with the switch off. We run that test in plain Node with Vitest, mock the cloudflare:workers module, and construct each entrypoint the way the runtime would, a fresh instance with its environment set. So we test the methods directly, not through a binding: the test does not exercise serialisation across the boundary or a real binding stub. Cloudflare’s route for that is @cloudflare/vitest-plugin, which runs tests inside the Workers runtime.
TEEI Language to Mentorship: a fetch binding into the same router
TEEI Language and TEEI Mentorship are two products on one shared platform, deployed from one codebase as two Workers. TEEI Language owns every cron schedule on the platform, so when a scheduled job has to run inside Mentorship, we dispatch it from the Language scheduler through a fetch binding. Since 13 October 2026 that includes Skills Academy’s scheduled jobs, which the Mentorship Worker serves on a hostname of its own.
Both Workers run the same router, so the Mentorship Worker already has a route for the work. Through a fetch binding we reuse that route, its middleware, its HTTP status handling and its own authentication: one route table, and no second interface.
Two things about that Request are easy to get wrong. The URL you give a fetch binding does not choose the Worker; the binding does. But the target Worker still sees the URL, and we decide which programme the platform is serving from the hostname. So we build the URL from Mentorship’s real public hostname, even though the request never travels to it. Before sending, we check that the URL’s origin is the one we expect for the caller’s environment. That check covers the URL only: which Worker receives the call is set by the binding in the deployed configuration.
We check the preconditions, start a deadline set for each job, and send with redirect: 'manual', so a 3xx arrives as a status instead of being followed. We never throw from the dispatch: in the code we read on 15 October 2026, we return seven named failure outcomes as data, and a missing binding is one of them. We accept a 2xx reply with a non-empty status string and retain the reported job outcome. We validate the reply’s shape without establishing that the job succeeded. When no job outcome comes back, we log the failed dispatch at error level and capture it in Sentry. When the peer reports an outcome, even with a non-2xx status, we can log the job as complete on the calling side, and the receiving job reports its own failures.
Where a failure is caught decides its name. We read the body before we test the status, inside its own catch, so an abort while we read the body of a 2xx reply becomes an invalid response, not a timeout. We infer this from the code; we have not observed it at runtime.
An abort on our side does not tell us whether the work ran on the other side, and the deadline gives no exactly-once guarantee. In the receiving jobs, we coordinate repeated calls with claims, leases and compare-and-set updates, and for email delivery we also reuse idempotency keys and recorded provider acceptances. That is what lets us abort a slow call and leave the rest to the next tick.
There is no local fallback: we never run the Skills Academy work inside a Language Worker that lacks the binding. We would rather a missing binding fail loudly every tick than have the work run quietly in the wrong Worker.
2 September 2026: the binding was not the fix
Until 2 September 2026, the scheduled release of mentor waves, a job TEEI Language starts and the Mentorship Worker carries out, went from one Worker to the other over a public fetch. From 06:45 UTC that day it failed every 15 minutes, and the Mentorship Worker logged no arriving requests. Our first diagnostic commit, at 09:48 UTC, said: “Neither end could name the cause, so the loop was unclosable.”
At 10:12 we moved the call onto a service binding. The theory was the one our overview describes, a Worker’s fetch to another Worker on the same zone. The release kept failing. At 11:05 we wrote down why we were stuck: “workerd raises TypeError for several unrelated refusals, so the name stopped being informative exactly where it mattered.” Error 1042 is one such refusal; a request option the runtime rejects is another, and the two need opposite fixes. The runtime states which in its message, and we had been throwing that message away to keep URLs out of our logs. From then on, we removed the URLs from the runtime’s message and kept up to 200 characters of it.
At 11:20 we read the runtime’s message:
Adapted for publication. We changed only the line breaks.
Invalid redirect value, must be one of follow or manual (error won't be implemented since it does not
make sense at the edge; use manual and check the response status code).
The call had set redirect: 'error'. On 2 September 2026, workerd threw that TypeError before the request was made, “so nothing is sent: the peer logs nothing, the firewall logs nothing”. The same option sat in a second call, the one that reconciles email delivery status with our email provider, which had never worked at all.
We have not reconstructed when the first successful release followed, so we give no recovery time.
In the scheduled dispatch to Mentorship, we now set redirect: 'manual' and treat a 3xx as an answer. On 15 October 2026 Cloudflare’s Request reference still listed error among the modes, and we have not re-run the refusal against today’s workerd, so check the version you deploy. The same page gives a reason to avoid the default as well: with follow, “all headers will be forwarded to the redirect destination, even if the destination is a different hostname or domain”, including Authorization. In a test, we walk our application and Worker sources for TypeScript files and fail on the literal redirect: 'error'. The test also requires the scan to have covered more than 200 files, so a moved directory cannot make it pass by reading nothing; plain JavaScript modules the Worker imports fall outside it.
We kept the binding. It was not the fix, but we documented in TEEI Language’s configuration that the call enters the Mentorship Worker directly rather than through the public hostname, and the same binding has carried the Skills Academy jobs since 13 October.
TEEI Language to Evenhall: a named entrypoint
Evenhall is where CSR, HR and sustainability leads at partner companies see what their employees did through TEEI’s programmes. From TEEI Language’s scheduler, we reconcile eligible employee-registration and completed-activity milestones for both programmes into Evenhall.
This call is RPC. We export CsrApplicationActivation from Evenhall as a class extending WorkerEntrypoint, and bind TEEI Language to that named entrypoint:
Adapted for publication. We replaced the two service names with placeholders and removed a third binding and its comment.
"services": [
{ "binding": "MENTORSHIP_SCHEDULED_WAVE_SERVICE", "service": "<mentorship-worker>" },
{
"binding": "CSR_APPLICATION_ACTIVATION",
"service": "<evenhall-worker>",
"entrypoint": "CsrApplicationActivation"
}
]
The two Workers live in separate repositories, and TEEI Language does not import Evenhall’s types. We documented the reason in a comment above the copied types: “Keep this Worker independently buildable; source-domain imports across repositories are forbidden.” So we copy the contract as types:
Verbatim excerpt.
type RpcResult<T> = { ok: true; data: T } | { ok: false; code: string; message: string };
export interface CsrActivationBinding {
targets(
cursor: string
): Promise<RpcResult<{ targets: CsrActivationTarget[]; nextCursor: string | null }>>;
record(event: CsrActivationEvent): Promise<RpcResult<{ recorded: boolean }>>;
}
We check that targets and record are functions, not that replies match our copied types. With { ok: true, data: { targets: null, nextCursor: null } }, the loop would fail before record(). If both Workers build together, typecheck them against one shared contract.
We keep the cursor in TEEI Language because we get a fresh entrypoint instance on every call. In a loop, we ask Evenhall for a page of companies with outstanding milestones, establish from TEEI Language’s own records which milestones to record, and call record() for each one. We page by a stable identifier rather than a row offset, so targets that drop off the list once complete do not shift later rows past the cursor. The cursor is a position in that order, not a snapshot: a target that appears behind it waits for the next run, which starts from an empty cursor. In Evenhall we derive nextCursor from the full database page, independently of the targets we return, so an empty target page can still have a next page; a cursor that does not move forward stops the loop with an error.
A crash halfway through the loop is safe to repeat, because we deduplicate recorded milestones durably in Evenhall. We accept an identical replay as recorded: false and refuse a replay that disagrees with the stored event, and we write the event and the milestone update in one transaction, keeping the earliest time for each milestone. We retry only acquiring that transaction. Once our statements have run, we do not replay an ambiguous commit or network failure as a fresh transaction; a later run relies on the deduplication instead.
Every method on the entrypoint returns { ok: true, data } or { ok: false, code, message } and does not throw across the binding. So we receive refusals as result data and failed calls as exceptions. In TEEI Language, a returned refusal becomes one fixed local error for targets() and another for record(), while a rejected call propagates as its own exception, so we keep which operation failed but discard Evenhall’s refusal code.
Returning codes as data also sidesteps a detail of RPC errors. An error thrown on the far side “propagates to the caller as a new error object”, and Cloudflare’s error handling page says that without the enhanced_error_serialization flag, RPC “does not preserve custom own properties”. The flag is on by default only for compatibility dates on or after 2026-04-21. TEEI Language and TEEI Mentorship run on compatibility date 2024-11-01 and Evenhall on 2026-04-01, and none of the three sets the flag. By the documentation, a custom code property on an error thrown in Evenhall would not reach TEEI Language; we have not tested that.
The binding was in the file, not in the Worker
We run Evenhall’s scheduled jobs in a small Worker of their own and connect it to Evenhall through a fetch binding. We documented the reason in its configuration: “Dispatch uses a service binding because a global fetch to another same-zone Worker fails with 1042”.
On 6 September 2026 we read that Worker’s record in our Cloudflare account. In a six-day window, it had run 29 times and thrown 27 times, each time after exactly one subrequest, and Evenhall had received none of its calls. Our runbook gives the cause: “The declared transport is a service binding, and the deployed Worker has none”. We had committed the binding on 5 September 2026, and the copy running in production had last been deployed on 12 July.
A binding is part of a Worker’s deployed configuration, and committing it changes nothing until that Worker is deployed again. We redeployed the scheduler to production on 17 September 2026. When we measured again on 11 October, all 40 of its scheduled runs from 4 to 10 October had status success. In our runbook, success means the handler did not throw; it does not prove that every call it made was answered with a 2xx.
How We Compare Builds and Verify a Worker Release covers how we tie a deploy to what was built. For bindings the rule is simpler: after changing one, look at the deployed Worker’s bindings, not at the file.
Binding in staging, public fetch in production
We send governance actions from our admin Worker to TEEI Language and TEEI Mentorship. In production we call both programmes through a public fetch, and we wrote the reason into the configuration: “Production has no binding on purpose: its origins are custom domains and answer.” A Custom Domain target is the case the custom domains page exempts from the same-zone rule.
In staging, a plain fetch from the admin Worker to TEEI Language was answered by something in between, a 403 HTML page, and the TEEI Language Worker’s log saw no request at all. A binding calls the Worker directly, so on 28 September 2026 we gave admin a binding to each programme in staging only, and the same code takes whichever path the environment has:
Verbatim excerpt.
const send = binding ? binding.fetch.bind(binding) : fetch;
For non-2xx replies without the receiving route’s expected outcome or error field, we log which path the call took and report the reply as a refusal from something other than TEEI Language. That is a check of the reply’s shape: it does not establish which component produced the response. Every call has a deadline, and beside the timeout handling we wrote down what a timeout means on any of these paths: “A timeout is genuinely ambiguous: the operation may have landed. The operation id is what makes retrying safe, so the screen tells the operator to retry rather than to redo.”
Why our monitoring takes the public road
A binding goes around the public edge. That is its advantage for scheduled work, and the reason we never use one to check health. A green binding call proves that one Worker can reach another inside our account. It can stay green while a certificate, DNS or zone problem breaks the route a learner takes.
We use a separate monitor Worker to run health checks through both programmes’ public hostnames, with manual redirects and a deadline, so we can keep monitoring during an application outage. After every Mentorship deploy, we run a probe that reads each public hostname that Worker serves, including Skills Academy’s, and checks that each one reports the same source revision as the canonical host. It compares reported revisions, not the deployed artefacts. We wrote the argument for it at the top of the probe on 13 October 2026: “Until this module, every post-deploy check read the canonical host only, so the second address could stop answering, or answer from another Worker, with every gate green.”
For the website we made the same choice for the same reason, as our overview describes: we call another of our Workers through the global_fetch_strictly_public flag, not a binding, so we exercise the public route.
What a binding costs, and what we have not measured
Cloudflare’s pricing page is short on this: “Requests made from your Worker to another worker via a Service Binding do not incur additional request fees.” A request that crosses a binding is billed as one request for the first Worker and “the total amount of CPU time used across both Worker A and Worker B”.
No additional request fee does not mean nothing is counted. Each call through a binding counts towards the caller’s subrequest limit, which the limits page sets at a default of 10,000 per invocation on Workers Paid, configurable up to 10 million. Cloudflare’s service bindings page allows at most 32 Worker invocations per request, each binding call included, while the errors page describes a guard against a Worker calling itself or another Worker more than 16 times, ending in error 1019; neither page says whether the two are one mechanism. A binding call does not count towards the limit on connections an invocation can hold open at once. In a write run of our Evenhall loop, we make one targets() call per page and one record() call per eligible event, also when the answer is recorded: false, so call volume and chain depth are different questions. Test sequential calls across one binding separately from a nested chain, and do not infer either limit from the other.
We have no figures for the latency or cost of a binding call against a public call, the number of binding calls per day, how often a binding call fails in production, or how many scheduled dispatches time out. We cannot isolate binding calls from the subrequest ratios in our overview, because they also count database and external calls. If you need those numbers, add a structured log line for each cross-Worker call with its outcome and its transport, configure invocation logging and sampling, and account for sampling before you derive totals.
Verify this
- Reproduce the bypass in your own account. Deploy two Workers. In B, export a class that extends
WorkerEntrypointwith one writing method, and give B afetchhandler that refuses POST requests while an environment variable is set. In A, declare one binding with"entrypoint"set to your class and one without. Set the variable and call both: the fetch binding is refused, the RPC write goes through. Then move the check into the method. Test the method directly for its behaviour, and separately inside the Workers runtime with@cloudflare/vitest-plugin, through a configured binding. - Read the bindings of the deployed Worker. After adding or changing a binding, compare the calling Worker’s deployed configuration with the file, and make one real call through the binding before you rely on it.
- Test the catch boundary. In a fixture, reject
fetch()withAbortError, then return a 2xx response whose body rejects with the same error. Expect a timeout first and an invalid response second. - Repeat a write after an ambiguous outcome. In a fixture, let a write commit and then lose the acknowledgement before it reaches the caller. Restart from an empty cursor and assert that the committed event is not created again, while outstanding milestones can still be recorded. Separately, fail the milestone update before commit and assert that neither change persists.
The incident reports, scheduler measurements and test details come from internal documentation that readers cannot independently inspect.
If you are splitting a system across Workers and want to compare notes, talk to us.
Frequently asked questions
When should one Worker call another through RPC instead of a fetch binding?
We use RPC for the typed methods Evenhall exposes to our own Workers, such as the entrypoint TEEI Language uses to record partner milestones. We use a fetch binding when an HTTP route already serves the work, as the Mentorship Worker does for scheduled jobs, so one route table answers both. Cloudflare’s service bindings page recommends RPC for most use cases.
Does a service binding cost extra on Cloudflare Workers?
Cloudflare documents no additional request fee for a call through a service binding: you pay for the first Worker’s request and for the CPU time used across both Workers. Each call still counts towards the caller’s subrequest limit and towards a limit on Worker invocations per request. We have not measured what our binding calls cost or how long they take.
Does middleware in a Worker’s fetch handler run for RPC calls?
No. When we call over RPC, we reach the method on the WorkerEntrypoint directly, without going through the fetch handler. On 10 October 2026 we closed a gap where a maintenance switch enforced in Evenhall’s fetch handler did not stop writes that arrived over RPC. We now check the switch in every writing RPC method, and we verify that behaviour in a test.
Why did fetch() with redirect error fail before sending anything?
On 2 September 2026 we saw workerd refuse the redirect mode “error” with a TypeError before the request left the Worker, so neither the caller’s peer nor any firewall logged it. Cloudflare’s Request documentation still lists error as a mode. In the scheduled dispatch to Mentorship we now use the mode “manual”, and in a test we scan our Worker sources for the other value. We have not re-run the refusal against today’s workerd.
Why does your monitoring not use the service binding?
A binding goes around the public edge, so when our binding calls succeed, we learn nothing about whether a visitor can reach the Worker. From our monitor Worker, we run health checks through both programmes’ public hostnames with a plain fetch. After each Mentorship deploy, we check every public hostname that Worker serves.