Rails: Building a single-page Backbone app without sacrificing Turbolinks.

Part 2: Avoiding the zombie encroachment, or managing memory without a page change.


In Part 1 we covered how to ensure Backbone and Turbolinks share the browser history in your app without conflict.

With the loss of the full page load goes an important fig leaf in poor Backbone memory management. You can no longer depend on an eventual page change to clean up objects you forgot to release. Over time this can result in hordes of zombies, double-renders and orphaned event callbacks.

Why does a page change matter anyway?

Central to this whole challenge is that, in JavaScript, an object cannot be garbage collected until it is no longer referenced by anything that is being retained.

Instead of a full page change, Turbolinks retains the active page and injects the body and title of the new one. Backbone views from the active page will stick around because they haven’t been released properly, merely replaced in the DOM. Accidentally retained objects will accumulate indefinitely as the user navigates. Even your Backbone app itself will stick around unless you clean it up.

Fortunately it isn’t as hard as it may seem to avoid situations where memory issues creep in. The constraint can even be a good thing, keeping you honest and encouraging healthy code. Here are a few guidelines that will help you stay out of danger.

1. Establish a view teardown strategy

Commonly this means adding a close() method to the Backbone.View prototype that removes the view element from the DOM and executes a callback if one exists.

Backbone.View::close = ->
@remove()
@onClose() if @onClose?
class ItemListView extends Backbone.View
onClose: ->
console.log "Item list view is being closed!"

Calling remove() on a view automatically removes the view element from the DOM, clears any bound events and data, and calls stopListening() on the view itself. Having a callback like the onClose method allows you to perform custom cleanup on the view itself. Derick Bailey first detailed this on his blog.

It’s up to you to make sure you track your subviews so that you can release them in the onClose callback.

onClose: ->
itemView.close() for itemView in @itemViews

2. Undelegate bound views

Backbone allows us to bind to existing DOM elements by specifying the el attribute when creating a view:

view = new MyApp.ItemListView(el: ‘#items’, collection: @items)

This time we can’t remove the element from the DOM when cleaning it up because the app may need to bind to it again if the user revisits a cached version of the page. No problem, we simply need to adjust our close() method to undelegate events manually and avoid removing the view element when told to do so.

Backbone.View::close = (remove = true) ->  
if remove
@remove()
else
@undelegateEvents()
@stopListening()
@onClose() if @onClose()

3. Don’t bind when you should listenTo

You’ll often find yourself binding to a lot of model or collection events like this:

initialize: ->
@model.on 'change:total', @refreshTotal, this
@model.on 'change:state', @refreshState, this
# ...

You may want to use listenTo() instead. The following code is analogous but allows the view to track events and unbind them with a single call to stopListening().

initialize: ->
@listenTo @model, 'change:total', @refreshTotal
@listenTo @model, 'change:state', @refreshState
# ...

Since stopListening() is called automatically when you close the view unbinding callbacks is now one less thing to worry about.

4. Beware of binding outside the view scope

You almost never want a view to bind to events outside its scope but in some cases, such as listening to the document for a particular keydown combination, it just can’t be helped.

These are amongst the most dangerous of bindings. If unremoved they will accumulate. If they reference the view it will stick around forever. If the view is destroyed they’ll trigger callbacks that no longer exist.

The onClose callback provides the obvious place to clear these up. It’s possible to namespace them by purpose so that they can be safely removed in groups.

class ItemListView extends Backbone.View
initialize: ->
$(document).on 'keydown.hotkeys', @respondToDeleteKeydown
$(document).on 'keydown.hotkeys', @respondToShiftCKeydown

onClose: ->
$(document).off('keydown.hotkeys')

However I’m still not happy with this approach. I find that these events almost always constitute a particular role that requires its own object or context, hotkeys being a good example.

I prefer to use a global pubSub object that extends Backbone.Events and can be listened to by any object in your app:

window.pubSub = _.extend({}, Backbone.Events)

The hotkeys functionality can be abstracted into a jQuery plugin that triggers events on the pubSub object. This setup is much more modular and clean. Your views listenTo the pubSub object and stopListening automatically when they’re closed.

initialize: ->  
@listenTo pubSub, 'hotkey:delete', @respondToDeleteKeydown
@listenTo pubSub, 'hotkey:shift_c', @respondToShiftCKeydown

All you have to worry about is releasing a single pubSub object and stopping the jQuery plugin when your app is closed. For more information on pubSub in Backbone check out Tim Ruffles’s blog post.

5. Tidy up after the page has changed

The last challenge is to make sure your app gets tidied up at the right time. This is straightforward enough — you probably want to create a close() method for your router that will close the views it has created. If you’ve used your onClose callbacks correctly this should bubble down into all subviews, removing their events and cleaning up the DOM elements they’ve created.

Let’s take our setup from Part 1 and keep a reference to the router so that we can close it later when we’re done. We’ll add a teardown method that so we can clean up our app and call it when the page:change event is fired.

window.MyApp =
Models: {}
Collections: {}
Views: {}
Routers: {}

initialize: ->
@router = new MyApp.Routers.Items()
Backbone.history.start()

teardown: ->
@router.close()
@router = undefined

$(document).on 'page:change', ->
if MyApp.router?
Backbone.history.stop()
MyApp.teardown()

if shouldLoadApp() # Function from part 1
# Initialize the app

There are a number of reasons for cleaning up after the page has changed.

  1. If the user navigates from the Backbone app page but hits the stop button, the app will still be active.
  2. If the user navigates from the Backbone app page it will retain its state and appearance until the page change has occurred.
  3. If the user navigates to the Backbone app page using the back or forward browser buttons the app will otherwise be in an unpredictable state.
  4. If it’s a full page load, the router won’t exist and no action will be taken.

Wrapping up

That’s it. These guidelines should help you keep your app clean. If you have any others I’d love to hear them —catch me on Twitter at @midhir.

The full source from this blog post and Part 1 is available as a gist.

Like what you read? Give Jon Hope a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.