Programming Languages Bug Report

Cannot read properties of undefined in a JavaScript class callback: this binding

A refactor passed a class method straight to .then() and our sync client started crashing with Cannot read properties of undefined (reading 'retries'). Methods do not carry their object with them. We compare bind, arrow fields and wrappers, including the arrow-field trap with subclasses.

John Nguyen John Nguyen @devbugs · July 01, 2025 · 8 min read · 35 Views
Banner: a method detached from its class object, floating with a broken link back to the instance

Our inventory sync service pushes stock levels from the warehouse system to three marketplaces. After a small refactor, the service started crashing on the first API response it received:

TypeError: Cannot read properties of undefined (reading 'retries')
    at handleResponse (/srv/sync/dist/client.js:41:18)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)

The line in question was the first statement of a method that had worked for a year:

handleResponse(res: Response) {
  if (!res.ok && this.retries < this.maxRetries) { ... }
}

Reproducing it

The refactor replaced an inline arrow function with a direct method reference, to "remove a pointless wrapper":

-return fetch(url, init).then((res) => this.handleResponse(res));
+return fetch(url, init).then(this.handleResponse);

Reverting that line made the crash disappear. Applying it to a five-line class reproduced the crash every time.

What we ruled out

  • this.retries not being initialised. It was set in the constructor, and logging this in the outer method showed the full instance.
  • A transpilation issue. We are on Node 22 with TypeScript targeting ES2022, so classes run natively. The behaviour was the same when running the TypeScript through tsx without a build.
  • The error coming from fetch. The stack pointed into our method, and res was a normal Response.

Root cause

this.handleResponse evaluates to the function stored on the class prototype. It does not remember which object it was read from. When the promise machinery later calls it, it calls it as a plain function with no receiver. Class bodies are always strict mode, and in strict mode a plain function call gets this === undefined. Reading undefined.retries throws.

What is happening under the hood

In JavaScript, this is decided at call time, by how a function is called, not where it was defined:

Call form this inside a normal function or method
obj.method() obj
const f = obj.method; f() undefined in strict mode (all class code), globalThis in sloppy scripts
promise.then(obj.method) undefined
arr.map(obj.method) undefined, unless you pass thisArg as the second argument
emitter.on("x", obj.method) the emitter object (Node EventEmitter sets it)
el.addEventListener("x", obj.method) the DOM element
setTimeout(obj.method, 10) in Node the Timeout object
f.call(obj), f.apply(obj), f.bind(obj)() obj
arrow function this of the enclosing scope, fixed at creation

The last rows explain why this bug sometimes looks different. With EventEmitter or setTimeout in Node, this is not undefined but some other object, so instead of a crash you get undefined property values and quiet misbehaviour: this.retries is undefined, undefined < 3 is false, and retries silently stop.

The inline arrow (res) => this.handleResponse(res) worked because the arrow captures this from the surrounding method, then makes a proper method call.

Environment

Component Version / value
OS Ubuntu 24.04 LTS (container)
Runtime Node.js 22.16.0
Language TypeScript 5.8, target: ES2022, strict: true
Lint (before) ESLint 9 with recommended only, no type-aware rules
Test runner Vitest 3.1

TypeScript did not catch it: the compiler allows passing a method reference as a callback unless the method declares a this parameter, and almost nobody writes one.

Minimal reproduction

class Client {
  constructor() {
    this.retries = 0;
  }
  handle(value) {
    return `${value} after ${this.retries} retries`;
  }
  run() {
    return Promise.resolve("ok").then(this.handle);
  }
}

new Client().run().catch((err) => console.error(err.message));
// Cannot read properties of undefined (reading 'retries')

Who is affected

Any code that passes a method as a value: promise callbacks, array methods, event handlers, timers, React class component handlers, and dependency injection that stores methods in maps. It appears most often during clean-up refactors that remove "unnecessary" arrow wrappers.

The fix

There are three standard fixes. We used the first at the call site and the third in shared base classes.

1. Keep an arrow wrapper at the call site. The original code was right:

return fetch(url, init).then((res) => this.handleResponse(res));

It is explicit, costs nothing measurable, and keeps handleResponse a normal prototype method.

2. Bind once in the constructor.

class MarketplaceClient {
  constructor(private readonly maxRetries = 3) {
    this.handleResponse = this.handleResponse.bind(this);
  }
  retries = 0;
  handleResponse(res: Response) { ... }
}

The bound function is an own property of the instance that shadows the prototype method. Every instance gets its own copy.

3. Use an arrow function class field.

class MarketplaceClient {
  retries = 0;
  handleResponse = (res: Response) => {
    if (!res.ok && this.retries < this.maxRetries) { ... }
  };
}

The arrow is created per instance during construction and captures that instance's this forever. You can pass client.handleResponse anywhere.

Alternatives and trade-offs

Arrow fields look like the cleanest answer, and in leaf classes they usually are. In a class hierarchy they nearly caused a second bug while we were fixing the first one.

Our base class for all three marketplace clients used an arrow field:

class BaseClient {
  handleResponse = (res: Response) => this.defaultHandling(res);
}

class AcmeMarketClient extends BaseClient {
  handleResponse(res: Response) {
    // custom rate-limit headers for this marketplace
    return super.handleResponse(res);
  }
}

TypeScript refused to compile this with error TS2425 (the base class defines handleResponse as an instance property, the subclass as a method), which is how we caught it. One of our older marketplace adapters is still plain JavaScript, and there nothing stops it. At runtime two things go wrong:

  • The subclass override is ignored. BaseClient's field initialiser runs during construction and assigns handleResponse as an own property of the instance. Own properties shadow prototype methods, including the subclass's handleResponse. Calls go to the base arrow, and the marketplace-specific handling never runs.
  • super.handleResponse is undefined. super looks up the parent prototype. The arrow lives on the instance, not on BaseClient.prototype, so the call fails with a TypeError saying it is not a function.

Arrow fields also cannot be spied on through the prototype in tests (vi.spyOn(BaseClient.prototype, "handleResponse") finds nothing) and add one closure per instance, which matters only for classes instantiated in very large numbers.

So our rule became:

Situation Choice
One call site passes the method arrow wrapper at the call site
Class meant to be subclassed prototype methods, bind in the constructor if needed
Leaf class used as event handler bag (UI components) arrow fields are fine

Binding in the base constructor works with overrides, because this.handleResponse.bind(this) looks up the most-derived method at construction time:

class BaseClient {
  constructor() {
    this.handleResponse = this.handleResponse.bind(this);
  }
  handleResponse(res: Response) { return this.defaultHandling(res); }
}

How to test the fix

Tests that exercise the method as a detached callback, plus the subclass case:

import { describe, expect, it, vi } from "vitest";
import { AcmeMarketClient, BaseClient } from "./clients";

describe("callback binding", () => {
  it("works when handleResponse is passed as a bare callback", async () => {
    const client = new BaseClient();
    const res = new Response(null, { status: 200 });
    await expect(Promise.resolve(res).then(client.handleResponse)).resolves.toBeDefined();
  });

  it("uses the subclass override", async () => {
    const client = new AcmeMarketClient();
    const spy = vi.spyOn(AcmeMarketClient.prototype, "readRateLimit");
    await client.handleResponse(new Response(null, { status: 429 }));
    expect(spy).toHaveBeenCalled();
  });
});
npx vitest run src/clients

Confirming in production

After the deploy we watched the error tracker and the sync metrics. The TypeError disappeared from the first sync cycle, and the retry counter (sync_http_retries_total per marketplace) showed non-zero values again for the marketplace that rate-limits aggressively. That second check mattered: while auditing, we found the webhook consumer registered the same method with emitter.on("response", this.handleResponse). There this was the emitter, so this.retries was undefined, the retry guard was always false, and it had been silently skipping retries for weeks. The same fix covered it. Stock levels on all three marketplaces matched the warehouse within one sync interval.

Regression guard

typescript-eslint's unbound-method rule reports passing a method reference that uses this without binding. It needs type information:

// eslint.config.js
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true } },
    rules: { "@typescript-eslint/unbound-method": "error" },
  },
);
src/client.ts
  58:35  error  Avoid referencing unbound methods which may cause unintentional scoping of `this`.
If your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead  @typescript-eslint/unbound-method

We also added a review note against arrow fields in classes that are designed for inheritance.

Security implications

None directly. The indirect risk was the silent variant: with this bound to an unexpected object, guard conditions such as this.retries < this.maxRetries or this.isAuthorized evaluate against undefined and can quietly take the wrong branch. Code that makes security decisions through this should never be passed around unbound, and the lint rule now enforces that.

Prevention checklist

  • Do not pass obj.method as a callback; wrap it in an arrow or bind it.
  • Prefer prototype methods in classes meant for inheritance; avoid arrow fields there.
  • Enable @typescript-eslint/unbound-method with type-aware linting.
  • Test callbacks in their detached form, the way callers use them.
  • Remember Node event emitters and timers set this to themselves, which hides the bug.

References

Add a label

#typescript #javascript #nodejs #eslint #classes
Written by
John Nguyen @devbugs

I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.