Prevent Backbone.js Memory Leaks with listenTo and View.remove()
Use Backbone's listenTo and View.remove() to stop detached views from handling stale model events.
21 May 2026, 08:07 UTC

The problem: a view that refuses to die
You navigate away from a Backbone list page. The view's element is gone from the DOM, yet the browser tab keeps using more memory. A moment later, a model fires a change event, and a handler inside the discarded view runs anyway. That handler tries to update a detached element, logs an error, or worse, silently mutates state no one can see.
This is the classic zombie view. The view is logically removed, but its event listeners are still attached to models, collections, or other views. Because those objects outlive the view, the view cannot be garbage collected.
Why listenTo and stopListening exist
Backbone.Events is a shared set of methods mixed into Model, Collection, View, and Router. It gives every object on, off, and trigger. The pair listenTo and stopListening inverts the wiring: instead of telling the source object to call your handler, you tell the listening object to watch the source. Backbone records that registration on the listener.
That distinction matters at teardown. If you used on directly, you must call off on every source object by hand. If you used listenTo, a single stopListening() removes every listener that view registered elsewhere.
Backbone.View.remove() already calls this.stopListening() before detaching the view's element from the DOM. That makes it the standard teardown hook for a view you are about to discard.
Worked example: tearing down a list of item views
Imagine a master/detail screen. A parent view renders several child item views. Each child can emit a select event. The parent needs to react, but it must not keep the children alive after a route change.
var ItemView = Backbone.View.extend({
events: {
'click': 'onClick'
},
onClick: function () {
this.trigger('select', this.model);
}
});
var ListView = Backbone.View.extend({
initialize: function () {
this.itemViews = [];
},
render: function () {
this.collection.each(function (model) {
var item = new ItemView({ model: model });
this.listenTo(item, 'select', this.onItemSelect);
this.$el.append(item.render().el);
this.itemViews.push(item);
}, this);
return this;
},
onItemSelect: function (model) {
console.log('selected', model.id);
},
remove: function () {
_.invoke(this.itemViews, 'remove');
this.itemViews = [];
return Backbone.View.prototype.remove.apply(this, arguments);
}
});
When the router leaves this route, call listView.remove(). Backbone detaches the element and calls stopListening(), which unbinds every select listener the parent registered on its item views. The custom remove above also removes each child view so its own listeners and element do not linger.
The limitation: remove() is not automatic disposal
stopListening()only removes listeners made withlistenTo. If any code in the view usedmodel.on('change', handler)directly, those bindings remain until you calloffmanually.View.remove()does not recurse into child views. You must remove subviews yourself, as shown above, or they keep rendering into detached DOM nodes.- Backbone 1.x is in maintenance mode. The API is stable, but no new features are coming. Pin a specific 1.x release and check the project's support status before starting new work.
How to verify the teardown
Run this check in your browser's DevTools console while the app is on the list route. Replace listView with your actual view instance and model with a model from that view's collection. No special permissions are required; this runs in the page's JavaScript context.
var fired = false;
model.on('change', function () { fired = true; });
model.set({ name: 'test' });
console.assert(fired === true, 'handler should fire before teardown');
listView.remove();
fired = false;
model.set({ name: 'test again' });
console.assert(fired === false, 'handler should NOT fire after teardown');
If the second assertion fails, the view still holds a listener. Check for direct on calls inside the view or child views that were not removed. Risk: passing this test only confirms listenTo bindings were cleared; direct on() bindings still need manual off().
For a broader check, switch routes repeatedly in a demo SPA and open Chrome DevTools > Performance > Memory. Take heap snapshots before and after. If detached view nodes and their listeners keep growing, a teardown path is missing.
Bottom line
Wire cross-object events with listenTo, not bare on. Override remove() to clean up subviews, timers, and third-party widgets, then call the parent implementation so Backbone runs stopListening() and detaches the element. That combination keeps discarded views from handling events they no longer should.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.