More Robust Browser-Side Networking

What happens to your web application when used over a flaky network connection? Does it swallow errors and stop responding? Does it bounce users immediately to the browser’s built-in “no connection” page? Does it give you a way to continue using the application when network connectivity is restored?

It used to be acceptable to simply say that a web application could only be used when there was a consistent, reliable network connection. They are web applications, after all. But it’s time to stop using that excuse.

As our JavaScript applications get more sophisticated and web technologies expand to include things like client-side storage and sockets, our love affair with jQuery’s thin wrapper around XMLHttpRequest needs to end. It’s time we paid some attention to the browser-side network layer.

The Challenge

I’m working on an Ember.js application using Ember Data to communicate with a pretty typical Rails API. The app is content-creation heavy, and we really don’t want people to lose data if one or more requests happen to fail because they briefly lose connectivity.

Ideally, we want to:

  • Make it obvious to the user that they have unsaved data, but let them keep working
  • Allow the system to resolve the problem itself if it can
  • Help the unsaved data survive a page refresh
  • Avoid rewriting our API
Ember provides some great tools for building robust web applications, but no easy way to do what we want to do out of the box. And reviewing the Ember Data adapters for building an offline-capable web application hasn’t led us to anything we’re thrilled about. They either require too many modifications to our API, abuse Ember Data internals, or are out of date. So, we’re tackling this ourselves.

A Reasonable Solution

The first step we’ve taken is to make saving data more robust. To begin, we needed to make some decisions and assumptions about the API and its interactions with the client:
  • We’re going to ignore the application’s data loading needs for the moment.
  • Some operations in our application involve copying parts of our relational object graph that are not loaded on the client. These can’t be completed while offline, but will be queued.
  • There are no side-effects on the server that are important to the client while offline. One example here is searching–the server updates a search index when content is saved, but we don’t expect the search function to work until the user is able to load data from the server. (See the first point above.)
  • The client app has an autosave that triggers periodically. For this first simple step, we’ll ensure that the saves persist to the API in order.
  • We won’t persist the data across page refresh yet.
  • It doesn’t take much (about 90 lines of CoffeeScript) to sketch out a little queue for all the mutating requests (PUT, POST, DELETE), halt processing when an error happens, and retry the requests on a scheduled basis. This isn’t enough for our end goals, but it seems to work really well as a first step and could be enough to prevent annoying errors or lost data in some cases.

A Note on Adapters

For this solution, we’re using the ActiveModelAdapter and swapped in a custom Synchronizer service in place of the adapter’s use of $.ajax. This would also work fine for the REST Adapter. Below is our re-implementation of the adapter’s ajax method to use that service.


ApplicationAdapter = DS.ActiveModelAdapter.extend
  synchronizer: Ember.inject.service('synchronizer')
  # Most of this is a boring re-implementation of the REST adapter's ajax method,
  # because there isn't a good way to swap it in otherwise.

  ajax: (url, type, options) ->

    new Ember.RSVP.Promise (resolve, reject) =>
      hash = @ajaxOptions(url, type, options)

      hash.success = (payload, textStatus, jqXHR) =>
        response = @handleResponse(
          jqXHR.status,
          Em.Object.create(),
          response || payload
        )
        if (response instanceof DS.AdapterError)
          Ember.run(null, reject, response)
        else
          Ember.run(null, resolve, response)

      hash.error = (jqXHR, textStatus, errorThrown) =>
        if (errorThrown instanceof Error)
          error = errorThrown
        else if (textStatus == 'timeout')
          error = new DS.TimeoutError()
        else if (textStatus == 'abort')
          error = new DS.AbortError()
        else
          error = @handleResponse(
            jqXHR.status,
            parseResponseHeaders(jqXHR.getAllResponseHeaders()),
            @parseErrorResponse(jqXHR.responseText) || errorThrown
          )
        Ember.run(null, reject, error)
        
      # Stop using jQuery's ajax directly
      # Ember.$.ajax(hash)
      @get('synchronizer').handleRequest(hash)

The Synchronizer code below works with the ways that the adapater uses the jQuery#ajax method, and with our other assumptions mentioned above. Notice that any GET is just a pass-through to $.ajax.


SynchronizationQueueEntry = Em.Object.extend
  init: ->
    @set 'headers', Em.Object.create({})

  type: null
  url: null
  data: null
  headers: null
  dataType: null
  contentType: null
  description: null

  # mimic a portion of the jqXHR interface to store headers in a serializable way
  setRequestHeader: (key, value) ->
    @get("headers").set(key, value)

  # returns a function that sets the necessary headers on a real jqXHR object
  getBeforeSend: ->
    ajaxHeaders = @get('headers')
    (xhr) ->
      for key in Ember.keys(ajaxHeaders)
        xhr.setRequestHeader key, ajaxHeaders[key]

  # returns an object to pass to jqXHR to perform the queued request
  jqueryOptions: Em.computed 'type', 'url', 'data', 'dataType', 'contentType', 'headers', ->
    type: @get('type')
    url: @get('url')
    data: @get('data')
    dataType: @get('dataType')
    contentType: @get('contentType')
    beforeSend: @getBeforeSend()

Synchronizer = Em.Object.extend
  syncPushQueue: null
  syncInProgress: false   # a request or chain of requests is in process
  syncingHalted: false    # there was a problem and the process is stopped
  retrySyncInSeconds: 1

  init: ->
    @set('syncPushQueue', Em.A([]))

  restartSyncing: ->
    if not @get('syncInProgress')
      @set('syncingHalted', false)
      @syncPush()

  handleRequest: (opts) ->
    if opts.type == "GET"
      Ember.$.ajax(opts)
    else
      entry = SynchronizationQueueEntry.create
        type: opts.type
        url: opts.url
        data: opts.data
        dataType: opts.dataType
        contentType: opts.contentType
      opts.beforeSend(entry)

      @get('syncPushQueue').pushObject entry

      # From the caller's standpoint, the handoff of the request was successful.
      # The ball is in the synchronizer's court now.
      opts.success({}, "success", {status: 200})


  _runSyncPush: Em.observer 'syncPushQueue.[]', ->
    # Observers run synchronously -- don't do any real work here other than setting
    # up the execution of a method later.
    Em.run.once @, 'triggerSyncPushFromQueueChange'

  triggerSyncPushFromQueueChange: ->
    if !@get('syncInProgress') and !@get('syncingHalted')
      @syncPush()

  syncPush: ->
    @set('syncInProgress', true)
    entry = @get('syncPushQueue.firstObject')
    if entry?
      Ember.$.ajax(entry.get('jqueryOptions')).then (result) =>
        @get('syncPushQueue').removeObject(entry)
        @set 'retrySyncInSeconds', 1
        @set 'syncingHalted', false
        Em.run.once @, 'syncPush'
      , (jqXHR, textStatus) =>
        if (jqXHR.readystate == 0)
          @set('syncingHalted', true)
          @set 'retrySyncInSeconds', @get('retrySyncInSeconds')*2
          @set('syncInProgress', false)
          Ember.run.later @, 'syncPush', @get('retrySyncInSeconds') * 1000
        else
          # TODO: Handle other errors
    else
      @set('syncInProgress', false)

Even these first fairly simple steps are enough to make our application more robust in the face of small problems with network connectivity. We plan to take this a few steps further so that users won’t lose their work if they refresh the page or close their browser tab before changes are synced to the server.