· 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 about this. 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 workerd enforces.
  • Do not detach platform functions. Keep fetch and friends as plain calls or wrap them in an arrow function.

# Questions, corrections or a similar problem to solve?