Take a Walk On the Client Side

Michael Hurley
Jan. 14 2014, 09:30AM EST

Do you have potentially complex objects you need ready access to on the client? The Indexed Database API—while still evolving—may be just the tool you need.

In today’s world where mobile browsing is king, it’s vital to be able to store your app’s state on the client-side. IndexedDB, the W3C's evolving API for persistent client-side storage. Especially with mobile browsers, you can't count on your users having network access all the time. In addition, it is just rude to constantly use your host's network connection to keep your app state consistent. IndexedDB lets you store your app's state on the client so it will continue to work even when the device is offline. It can help you minimize the amount of network access your app is consuming. And you want your app to start up and restore its state quickly--not wait around for app data to come over the network. Once again, IndexedDB to the rescue.

All of this is not to say, however, that IndexedDB is right for everyone in every situation. Indeed, as mentioned earlier, the spec is not final yet, and so the API is subject to change. For example, the setVersion method was deprecated in December 2011 in favor of the onupgradeneeded callback. While these kinds of tweaks are not game-changers, they are something that users and potential users will have to watch over time.

Why not LocalStorage?

You may also be wondering, “Why not just use LocalStorage?” Well, the LocalStorage API is great for many applications, but it has some drawbacks. These include:

  • It blocks (that is, it is synchronous).
  • It can store only String->String key-value pairs. You can work around this by “stringifying” your objects on the way in and parsing them on the way out, but that just increases the time it takes to put and fetch your data.
  • LocalStorage does not have any way to index values inside the objects you store.

Now, you could argue that you can wrap the blocking API in a promise- or callback-based asynchronous API. And you can work around the String->String key-value pairs by stringifying objects when you store them and parsing them when you fetch them. You could even hack together some indexing system for your localStorage objects. But why do all that work when IndexedDB will do it for you from the get-go?

What happened to WebSQL?

WebSQL charged out to a big lead in the client-side storage race. Apple and Google both built support for it into their browsers (even the Android browser). But then a funny thing happened: IndexedDB became the W3C spec, and WebSQL was deprecated (in November 2010). At publication time, we are in a situation where the most popular mobile browsers (Safari on iOS, the Android browser and Chrome on Android) support WebSQL. Safari does not support IndexedDB at all; and the Android browser only added IndexedDB support as of 4.4. Chrome has supported IndexedDB since version 29.

Other mobile browsers (including IE Mobile and Firefox) support IndexedDB, but do not support WebSQL at all.

The IndexedDB API

The hierarchy of the IndexedDB API is:

  • There is one origin;
  • An origin may have several databases;
  • A database may have several object stores
  • An object store is a collection of zero to n records; and
  • A record is a key-value pair.

To create a database:

var db; // this will be our handle on the database
var request = window.indexedDB.open("chessProblems", 1);

The call to 'open' returns an 'IDBRequest' object we can attach handlers to. If the database does not exist, then 'open' will try to create it. If it succeeds, then our 'onupgradeneeded' callback will get called:

request.onupgradeneeded = function(e) {
    // create initial object stores etc.
};

'onupgradeneeded' will also get called if the 'version' of the database—the second argument to 'open'—is greater than the current database version.

If the database exists, and 'open' successfully opened it, our 'onsuccess' handler gets called:

request.onsuccess = function(e) {
    db = e.target.result;
};

And the 'onerror' handler will fire when something goes wrong.

request.onerror = function(e) {
     // handle the error somehow ...
};

Object stores

You can think of object stores as analogous to tables in a SQL-style relational database. But there are significant differences. The primary difference is that you do not need to specify a schema for your IndexedDB object store. Another difference is that an object store may contain complex, deeply nested objects—something that might be represented by several tables in a relational database.

When you create the object store, you can specify an index on the store for quick lookup of objects. It may be auto-generated or specified by path. This is where IndexedDB really differentiates itself from localStorage because you can use indices on your objects to rapidly fetch deeply nested objects.

When you create the object store, you can specify a key on the store, or tell the system to auto-generate a key:

// create an object store and specify keypath/index
db.createobjectstore(‘keyedStuff’, {keyPath: ‘stuffKey’});

// use autoincrementing key 
db.createobjectstore(‘autoIncStuff’, {autoincrement: true});

Note that the keyPath may refer to a Number or String primitive, a date or an array (subject to certain constraints). The "value" can be any JavaScript primitive or object, including the new Blob, File and ImageData objects.

Where IndexedDB really separates itself from localStorage is with, well, indices. You can set indices on your object stores to rapidly fetch deeply nested objects. You can create constraints on your indices as well, for example, adding the `unique` flag enforces that the indexed property exists on at most one object in the store.

// consider an objectstore of stores
var store = db.createobjectstore(‘stores’, {keyPath: ‘id’});
// each store has a unique address:
store.createindex(‘streetAddress’, ‘streetAddress’, {unique: true});
// but we may have many stores in one state
store.createindex(‘state’, ‘state’, {unique: false});

Transactions

Now let’s manipulate our data. All operations on the database are wrapped in a Transaction. The Transaction interface is also asynchronous.

To read records from the store:

// add a new object to the store
var xact = db.transaction([“stores”]);
xact.onsuccess = function() { /* handle success … */ };
xact.onerror = function(e) { /* handle failure … */ };
var store = xact.objectStore(‘stores’);
var req = store.get(‘id1234’);
req.onsuccess = function(e) {
       var result = e.target.result;
       // now do something with the object you just fetched with ID `id1234`
}; 

When you fetch several records, you open a cursor and iterate over them in the `onsuccess` handler, e.g.:

store.openCursor().onsuccess = function(e) { 
     var cursor = e.target.result; 
      /* iterate with the cursor */ 
};

To write records to the store:

var storeObj = { /* etc. */ };
var req = db.transaction([“stores”], ‘readwrite’)
                 .objectStore(‘stores’)
                 .add(storeObject);
req.onsuccess = function(e) { /* handle success… */ };

Note that I passed a second argument to the transaction method. If you don’t specify ‘readwrite’ your transaction will be read-only by default. You may also explicitly pass ‘readonly’.

To update a record, first get the object out of the store, update the object, the `put` it back in the store:

var stores = db.transaction([“stores”], ‘readwrite’)
                      .objectStore(‘stores’);
var req = stores.get(‘id1234’);
req.onsuccess = function(e) { 
      var storeObj = e.target.result;
      // we got the object we want to update, so update it:
      storeObj.city = ‘Gotham’;
      //then write it back:
      var req2 = stores.put(storeObj);
      req2.onsuccess = function() { /* handle success … */ };
      req2.onerror = function(e) { /* handle failure … */ };
};
req.onerror = function(e) { /* handle failure … */ };

And finally, to delete a record:

var req = db.transaction([“stores”], ‘readwrite’)
                 .objectStore(‘stores’)
                 .delete(‘id1234’);
            
req.onsuccess = function(e) { /* handle success… */ };
req.onerror = function(e) { /* handle failure … */ };

One word of caution: Make sure you leave your database in a consistent state at the end of every transaction. If the user shuts down the browser in the middle of the next transaction, your database will revert to its prior state.

Developer resources

The Chrome browser has an IndexedDB viewer as part of its Developer Tools suite. There is a plug-in in early development for Firefox. Internet Explorer users can make use of the IDBExplorer package.

For browsers that do not support IndexedDB, you can try shimming support for the IndexedDB API over WebSQL. Beware, though, since the IndexedDB API is still a moving target.

Looking into the future

Future enhancements to the API are expected to include a synchronous interface for use with Web Workers. In addition, stabilization of the API will enable to polyfills to bridge the gap between IndexedDB and WebSQL. (In fact, this is already happening.)

Indeed, while IndexedDB may be a moving target, it is one worth following.

Michael Hurley

Comments