· 4 min read
Illegal invocation: the Cloudflare Workers bug every test missed
The contact form answered 502 on staging, the email provider saw no request and all 57 tests were green. The cause was a single line that called fetch as a method, which the Workers runtime rejects and Node does not.
- Cloudflare Workers
- TypeScript
- Debugging
- Testing
The symptom
The first deploy of this site’s contact form to staging looked fine until a real message was sent.
The form answered 502 email_failed, and wrangler tail showed the same line for every submission:
[contact] email send failed code=network_error status=none
The email provider’s dashboard showed no request at all. In the same request, the Turnstile
verification had already succeeded, and it also calls an external API with fetch. So outbound
requests worked; only this one never left the Worker.
The code
The email client is a small class. To make it testable, fetch can be injected, with the global
one as default:
export class ResendEmailSender implements EmailSender {
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: ResendSenderOptions) {
this.fetchImpl = options.fetchImpl ?? fetch;
}
async send(email: OutgoingEmail) {
// ...
try {
response = await this.fetchImpl(RESEND_API_URL, { method: 'POST', headers, body, signal });
} catch {
if (attempt < maxAttempts) continue;
throw new EmailDeliveryError('network_error', null);
}
// ...
}
}
Two details matter. The catch throws away the error it caught, so every failure before an HTTP
response becomes network_error. And fetch is called as this.fetchImpl(...).
Why it only fails on Workers
this.fetchImpl(...) is a method call: inside fetch, this is the sender instance. The Workers
runtime checks the receiver of its built-in functions, and a fetch called on a foreign object is
rejected before any network activity:
TypeError: Illegal invocation: function called with incorrect `this` reference.
Node’s fetch does not perform that check, so the same line works there. The Turnstile client got
away with it because it calls the function differently:
const fetchImpl = options.fetchImpl ?? fetch;
await fetchImpl(SITEVERIFY_URL, { ... }); // plain call: no foreign `this`
Same function, same default, different call site.
Why the tests missed it
Two reasons, and both are worth remembering:
- The tests ran in Node, where the check does not exist.
- Every test injected a mock
fetch, a plain function that does not care aboutthis. The default branch,?? fetch, was never executed by any test.
The suite covered the behaviour of the sender in detail and never ran the one line that production uses.
Reproducing it in the real runtime
wrangler dev runs Workers code in workerd, the same runtime as production. A throwaway Worker
that called the real sender, plus the same pattern without the catch, settled it in one request:
{
"sender": { "code": "network_error", "status": null },
"pattern": {
"name": "TypeError",
"message": "Illegal invocation: function called with incorrect `this` reference."
}
}
The fix
Never store the platform function itself; store a function that calls it:
this.fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
fetch.bind(globalThis) works too. With the fix, the same throwaway Worker and a deliberately
invalid API key got an HTTP 401 back from the provider: the request was finally leaving the Worker.
Making the next failure visible
Retries would not have helped: the call failed the same way every time. What was missing was the
real error. The catch now logs its name, message and cause, with the API key redacted from the
text, while the client still receives only a generic error:
} catch (err) {
console.warn(`[email] resend request failed attempt=${attempt}/${maxAttempts} ${describeError(err, [apiKey])}`);
if (attempt < maxAttempts) continue;
throw new EmailDeliveryError('network_error', null);
}
With that log in place, this bug would have been a one-line diagnosis instead of an investigation.
A test that encodes the runtime’s rule
The regression test replaces the global fetch with one that behaves like the Workers runtime:
it rejects any call with a foreign this. Then it uses the sender without injecting anything.
const impl = function (this: unknown, input: RequestInfo | URL) {
if (this !== undefined && this !== globalThis) {
return Promise.reject(new TypeError('Illegal invocation: function called with incorrect `this` reference.'));
}
return Promise.resolve(Response.json({ id: 're_global' }));
};
vi.stubGlobal('fetch', impl);
await expect(new ResendEmailSender({ apiKey: 're_key' }).send(email)).resolves.toEqual({ messageId: 're_global' });
On the old code it fails with the same message as production. On the new code it passes.
Takeaways
- Do not swallow errors. Map them to something safe for the client, but log the original for yourself, without secrets.
- Test the default path, not only the injected one. Dependency injection makes code testable and can also hide the one branch that runs in production.
- Run it in the target runtime before trusting green tests. A unit suite in Node says nothing
about rules that only
workerdenforces. - Do not detach platform functions. Keep
fetchand friends as plain calls or wrap them in an arrow function.
# Questions, corrections or a similar problem to solve?