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.
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.retriesnot being initialised. It was set in the constructor, and loggingthisin 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
tsxwithout a build. - The error coming from
fetch. The stack pointed into our method, andreswas a normalResponse.
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 assignshandleResponseas an own property of the instance. Own properties shadow prototype methods, including the subclass'shandleResponse. Calls go to the base arrow, and the marketplace-specific handling never runs. super.handleResponseisundefined.superlooks up the parent prototype. The arrow lives on the instance, not onBaseClient.prototype, so the call fails with aTypeErrorsaying 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.methodas 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-methodwith type-aware linting. - Test callbacks in their detached form, the way callers use them.
- Remember Node event emitters and timers set
thisto themselves, which hides the bug.
References
- MDN, this: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this
- MDN, Function.prototype.bind(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
- MDN, public class fields: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields
- MDN, super: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/super
- typescript-eslint, unbound-method: https://typescript-eslint.io/rules/unbound-method/
- Node.js events, passing arguments and this to listeners: https://nodejs.org/api/events.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/javascript-this-undefined-class-method-callback" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.