Async UIs with Backbone
Alex MacCaw wrote an article on strategies for building asynchronous UIs. He argues that perceived speed is what ultimately is important for users. I agree. Since we are dealing with asynchronous requests on the client, the UI can update independently of what happens on the server. A few things need to be handled when the user drives the updates on the page:
- Prevent the lost update problem
- Prevent pending requests from being aborted
- Prevent redundant requests
Process Ajax requests serially
To handle the first point, a generic solution could involved sending async requests serially. That is, async requests are not sent in parallel.
The code below requires Backbone as of commit 87c9b17a which uses
Backbone.ajax rather than
$.ajax in the
# Cache Backbone ajax function, by default, which is $.ajax by default _ajax = Backbone.ajax # Override Backbone.ajax to queue all requests to prevent lost updates. Backbone.ajax = (options) -> @ajax.queue options Backbone.ajax.pending = false Backbone.ajax.requests =  # Process the next request if one exists Backbone.ajax.requestNext = -> if (next = @requests.shift()) @request(next) else @pending = false # Sends the request and sends the next request when complete Backbone.ajax.request = (options) -> complete = (xhr, status) => if options.complete then options.complete(arguments) @requestNext() options.complete = complete _ajax(options) # Queue up requests Backbone.ajax.queue = (options) -> if @pending @requests.push options else @pending = true @request options
Hmm, what about
GET requests are safe, so I prefer them to not be queued and be sent in parallel to the requests in queue (
POST, etc.). Here is the modified
queue method and a slightly modified
request method to prevent processing the queue.
Backbone.ajax.queue = (options) -> # If type is undefined, it defaults to GET type = (options.type or 'GET').toUpperCase() if type is 'GET' @request options, false else if @pending @requests.push options else @pending = true @request options Backbone.ajax.request = (options, trigger=true) -> complete = (xhr, status) => if options.complete then options.complete(arguments) if trigger then @requestNext() options.complete = complete _ajax(options)
What about timeouts?
Timeouts should rarely occur, but if they do it's nice to have a retry mechanism in place. This is applied in the
complete handler and uses
_ajax directly (derived from this example).
MAX_ATTEMPTS = 3 ATTEMPTS = 0 Backbone.ajax.request = (options, trigger=true) -> complete = (xhr, status) => if status is 'timeout' if ATTEMPTS < MAX_ATTEMPTS ATTEMPTS++ return _ajax(options) if options.complete then options.complete(arguments) if trigger then @requestNext() # Each new request from the queue will reset the number of attempts # that have been made. ATTEMPTS = 1 options.complete = complete _ajax(options)
Prevent pending requests from being aborted
Using the code from above, we can check if there are pending (non-
GET) requests. For the off chance a user beats the server, this ensures they are
aware of it. Attach a handler to
window's onbeforeunload event.
$(window).on 'beforeunload', -> if Backbone.ajax.pending return "Whoa you're quick! We are saving your stuff, " "it will only take a moment."
Prevent redundant requests
To have a truly responsive UI,
keyup events are typically bound to provide immediate feedback to the user. A common example is editing a value in a input field which is being displayed in some other element on the page. As the user changes the value in the text input, the other element is updating immediately. This provides a nice user experience, but if naively handled, this could result in a whole lot of unnecessary requests.
Debounce is a function wrapper that rolls up multiple invocations of the same function and defers execution of the function after some amount of time. Here is a common Backbone pattern:
class Model extends Backbone.Model initialize: -> @on 'change', @save
class Model extends Backbone.Model initialize: -> @on 'change', _.debounce => @save() , 500
In this case only the last
change event matters. After 500 milliseconds pass, the function will execute calling the
This solution is great because it does not interfere with the
change event directly and allows for other handlers (that are not debounced) to be executed immediately, such as updating the UI.
# An input field may be constantly setting a value on the bound model class Input extends Backbone.Model events: 'keyup': 'update' update: -> # The change event is fired on every set that alters the value @model.set 'title', @$el.val() #... while an H1 element is displaying a value elsewhere class H1 extends Backbone.Model initialize: -> # Executes render immediately which updates the UI immediately @model.on 'change:title', @render render: => @$el.text @model.get 'title'
- Handle response errors, should the queue continue to be processed?
- Implement logic for having separate queues for different endpoints. This enables passing a
greedyoption to not wait for the whole queue, but rather simply wait for it's own queue to finish.