Article summary
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.