The case for native observation

Modern JavaScript apps spend a surprising amount of time doing one thing: figuring out whether data changed. Libraries that offer data-binding all solve the same problem, but they currently do so with different trade-offs. Some wrap your data in container objects, sacrificing the ability to work with plain JavaScript. Others fall back on dirty-checking, which burns CPU cycles comparing snapshots of your model to detect mutations.

Object.observe(), now available in Chrome 36, proposes a different answer. It's a native, asynchronous API for watching JavaScript objects for changes, letting you get a time-ordered sequence of change records whenever an observed object is mutated. No framework required:

// Let's say we have a model with data
var model = {};

// Which we then observe
Object.observe(model, function(changes){

    // This asynchronous callback runs
    changes.forEach(function(change) {

        // Letting us know what changed
        console.log(change.type, change.name, change.oldValue);
    });

});

Every change you make gets reported:

Change reported.

That opens the door to building two-way data-binding without pulling in a full framework. That doesn't mean you should ditch the frameworks you use. They still provide value in large projects where they impose structure, simplify onboarding and reduce boilerplate. But with Object.observe() in the platform, those same frameworks gain the option of a faster observation layer under the hood — one benchmark Angular ran last year showed dirty-checking taking ~40ms per model update, while Object.observe() completed the equivalent work in 1-2ms.

Why today's approaches are slow

There are four kinds of changes you typically want to observe:

  • Changes to raw JavaScript objects
  • Properties being added, modified or removed
  • Array elements being spliced in or out
  • Changes to an object's prototype

Data-binding matters because HTML is static, and keeping the DOM in sync with application state by hand is repetitive and error-prone. The pain grows with the number of properties and view elements you need to wire together, especially inside single-page apps. Today's frameworks use two main strategies to handle this:

Dirty-checking. Angular and similar MV* libraries detect changes by periodically comparing a model's current values to its previous state. The cost of that work scales with the total number of observed objects, not the number of changes. You also need a mechanism to trigger a digest cycle whenever data might have changed — something that's never quite a perfect science. The benefit is that you work with raw JavaScript objects, but the algorithmic cost is real.

Container objects. Ember, Backbone and others internally wrap your data in objects that intercept accessors and broadcast changes as they happen. This gives better algorithmic behavior — the cost of discovering changes scales with the number of changes, not the size of the object graph. But there's a fundamental friction: your code now has to deal with specialized objects instead of plain JSON. Data from the server needs conversion, and the observable objects don't compose cleanly with ordinary JavaScript utilities that expect raw data.

The ecosystem has been pushed into these camps by the absence of a platform primitive. Natively observing data means frameworks don't have to rely on these hacks, and smaller applications can skip the abstraction entirely.

Observing plain objects

With Object.observe(), you get the appeal of dirty-checking — working with vanilla JavaScript objects — without the repeated scanning. Start with a simple model object:

// A model can be a simple vanilla object
var todoModel = {
  label: 'Default',
  completed: false
};

Define a callback that handles any mutations:

function observer(changes){
  changes.forEach(function(change, i){
      console.log('what property changed? ' + change.name);
      console.log('how did it change? ' + change.type);
      console.log('whats the current value? ' + change.object[change.name]);
      console.log(change); // all changes
  });
}

Then register the observer, passing the object and the callback:

Object.observe(todoModel, observer);

Now mutate the model:

todoModel.label = 'Buy some more milk';

The console reports exactly what changed, how it was changed and what the new value is:

Console report

Updates behave the same way when changing an existing property like completeBy:

todoModel.completeBy = '01/01/2014';

And the report you get back reflects the change clearly:

Change report.

Deleting a property also produces a proper change record:

delete todoModel.completed;
Completed

That gives you visibility into additions, deletions, reconfigurations of properties and changes to the object's prototype. When you want to stop observing, call Object.unobserve() with the same signature:

Object.unobserve(todoModel, observer);

After you unbind, mutations no longer produce records:

Mutations

Filtering with an accept list

You're not forced to hear about every kind of change. The third argument to Object.observe() lets you specify an accept list, restricting the callback to only the change types you care about:

Object.observe(obj, callback, optAcceptList)

Imagine you want updates but not additions:

// Like earlier, a model can be a simple vanilla object

var todoModel = {
  label: 'Default',
  completed: false

};

// We then specify a callback for whenever mutations 
// are made to the object
function observer(changes){
  changes.forEach(function(change, i){
    console.log(change);
  })

};

// Which we then observe, specifying an array of change 
// types we're interested in

Object.observe(todoModel, observer, ['delete']);

// without this third option, the change types provided 
// default to intrinsic types

todoModel.label = 'Buy some milk'; 

// note that no changes were reported

If you then delete the label property, you'll get a report because deletion is a change type you subscribed to:

delete todoModel.label;

When no accept list is given, the default is the intrinsic set: add, update, delete, reconfigure and preventExtensions.

Notifications for custom behaviors

Observation also works with the notion of notifications, modeled on Mutation Observers. Rather than firing synchronously, notifications are delivered at the end of the microtask. In the browser that means at the end of the current event handler — a turn-based model where an observed object's work happens in one unit, and observers get their shot afterward.

The workflow looks like this:

Notifications

Here's a practical example that uses a notifier to define custom change notifications:

// Define a simple model
var model = {
    a: {}
};

// And a separate variable we'll be using for our model's 
// getter in just a moment
var _b = 2;

// Define a new property 'b' under 'a' with a custom
// getter and setter

Object.defineProperty(model.a, 'b', {
    get: function () {
        return _b;
    },
    set: function (b) {

        // Whenever 'b' is set on the model
        // notify the world about a specific type
        // of change being made. This gives you a huge
        // amount of control over notifications
        Object.getNotifier(this).notify({
            type: 'update',
            name: 'b',
            oldValue: _b
        });

        // Let's also log out the value anytime it gets
        // set for kicks
        console.log('set', b);

        _b = b;
    }
});

// Set up our observer
function observer(changes) {
    changes.forEach(function (change, i) {
        console.log(change);
    })
}

// Begin observing model.a for changes
Object.observe(model.a, observer);
Notifications console

The async design isn't accidental. Synchronous observation would mean a single property update could invite arbitrary observer code to run mid-function, invalidating your assumptions while you're still executing. Observers, in turn, would get called in the middle of operations and would have to defend against inconsistent state. Making the notification process asynchronous is harder for the library writer but safer for the application: observers see the world in a consistent state, and your functions can run to completion without interference.

The spec adds one more mechanism, synthetic change records, to handle cases where the default observation types don't capture how you're using the object — such as tracking get and set accessors through the custom notifier path shown above.

Synthetic change records

Accessor and computed properties are not automatically observable — if you implement them, it's your responsibility to notify the system when their values change. This works through notifier.notify(), another part of Object.observe(). While there are many possible approaches to observing derived values, O.o() deliberately makes no judgment about which is "right": computed properties should be accessors that call notify() when their internal state changes. Libraries are expected to help reduce the boilerplate around this.

Consider a circle class with a radius accessor. When the radius changes, the accessor notifies for itself, and that change is delivered together with changes from any other observed objects. This is the strategy you adopt for synthetic properties in your own objects; once it's in place, it fits into the broader observation system.

function Circle(r) {
  var radius = r;
 
  var notifier = Object.getNotifier(this);
  function notifyAreaAndRadius(radius) {
    notifier.notify({
      type: 'update',
      name: 'radius',
      oldValue: radius
    })
    notifier.notify({
      type: 'update',
      name: 'area',
      oldValue: Math.pow(radius * Math.PI, 2)
    });
  }
 
  Object.defineProperty(this, 'radius', {
    get: function() {
      return radius;
    },
    set: function(r) {
      if (radius === r)
        return;
      notifyAreaAndRadius(radius);
      radius = r;
    }
  });
 
  Object.defineProperty(this, 'area', {
    get: function() {
      return Math.pow(radius, 2) * Math.PI;
    },
    set: function(a) {
      r = Math.sqrt(a/Math.PI);
      notifyAreaAndRadius(radius);
      radius = r;
    }
  });
}
 
function observer(changes){
  changes.forEach(function(change, i){
    console.log(change);
  })
}
Synthetic change records console

Accessor properties and change semantics

A quick note on why accessors work differently from data properties. As mentioned, only value changes are observable for data properties — not for accessors. The underlying reason is that JavaScript has no notion of a change in value to an accessor; an accessor is just a pair of functions. Assigning to an accessor simply invokes the function; from the runtime's perspective, nothing changed, only some code got a chance to run.

Semantically, though, an assignment like circle.radius = 5 should give us a way to know what happened. This is fundamentally unsolvable: there's no system-wide way to interpret arbitrary code inside an accessor. The setter can do anything — for example, updating its value on every access — so asking whether the value "changed" doesn't meaningfully apply.

Single callback, multiple objects

Another pattern O.o() supports is a single callback observer. One callback can act as an observer for many different objects at once, receiving the full set of changes to all of them at the end of the microtask — similar in timing to Mutation Observers.

Observing multiple objects with one callback

Large-scale changes

For large applications that regularly perform sweeping updates, broadcasting hundreds of individual property changes is wasteful. Objects should instead be able to describe larger semantic changes in a compact way. Two utilities handle this: notifier.performChange() and the notifier.notify() method already introduced.

Large-scale changes

Here's an example with a Thingy object exposing math utilities (multiply, increment, incrementAndMultiply). Each utility wraps its work in notifier.performChange('foo', performFooChangeFn), signalling that a collection of operations constitutes one specific type of change.

function Thingy(a, b, c) {
  this.a = a;
  this.b = b;
}

Thingy.MULTIPLY = 'multiply';
Thingy.INCREMENT = 'increment';
Thingy.INCREMENT_AND_MULTIPLY = 'incrementAndMultiply';

Thingy.prototype = {
  increment: function(amount) {
    var notifier = Object.getNotifier(this);

    // Tell the system that a collection of work comprises 
    // a given changeType. e.g
    // notifier.performChange('foo', performFooChangeFn);
    // notifier.notify('foo', 'fooChangeRecord');
    notifier.performChange(Thingy.INCREMENT, function() {
      this.a += amount;
      this.b += amount;
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.INCREMENT,
      incremented: amount
    });
  },

  multiply: function(amount) {
    var notifier = Object.getNotifier(this);

    notifier.performChange(Thingy.MULTIPLY, function() {
      this.a *= amount;
      this.b *= amount;
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.MULTIPLY,
      multiplied: amount
    });
  },

  incrementAndMultiply: function(incAmount, multAmount) {
    var notifier = Object.getNotifier(this);

    notifier.performChange(Thingy.INCREMENT_AND_MULTIPLY, function() {
      this.increment(incAmount);
      this.multiply(multAmount);
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.INCREMENT_AND_MULTIPLY,
      incremented: incAmount,
      multiplied: multAmount
    });
  }
}

Two observers then watch the object: a catch-all that reports everything, and one that filters for the specific change types defined (Thingy.INCREMENT, Thingy.MULTIPLY, Thingy.INCREMENT_AND_MULTIPLY).

var observer, observer2 = {
    records: undefined,
    callbackCount: 0,
    reset: function() {
      this.records = undefined;
      this.callbackCount = 0;
    },
};

observer.callback = function(r) {
    console.log(r);
    observer.records = r;
    observer.callbackCount++;
};

observer2.callback = function(r){
    console.log('Observer 2', r);
}

Thingy.observe = function(thingy, callback) {
  // Object.observe(obj, callback, optAcceptList)
  Object.observe(thingy, callback, [Thingy.INCREMENT,
                                    Thingy.MULTIPLY,
                                    Thingy.INCREMENT_AND_MULTIPLY,
                                    'update']);
}

Thingy.unobserve = function(thingy, callback) {
  Object.unobserve(thingy);
}

Creating a new Thingy, observing it, and running the utilities produces:

var thingy = new Thingy(2, 4);
// Observe thingy
Object.observe(thingy, observer.callback);
Thingy.observe(thingy, observer2.callback);

// Play with the methods thingy exposes
thingy.increment(3);               // { a: 5, b: 7 }
thingy.b++;                        // { a: 5, b: 8 }
thingy.multiply(2);                // { a: 10, b: 16 }
thingy.a++;                        // { a: 11, b: 16 }
thingy.incrementAndMultiply(2, 2); // { a: 26, b: 36 }
Large-scale changes

Everything inside the perform function counts as the work of that "big change." Observers that accept the big-change record receive only it; observers that don't accept it receive the underlying individual changes that resulted from the work.

Observing arrays

Arrays have their own method: Array.observe(). It treats large-scale changes — splice, unshift, or anything that implicitly alters the length — as a single "splice" change record, internally using notifier.performChange("splice", ...).

Observing a model array yields a list of changes whenever the underlying data shifts:

var model = ['Buy some milk', 'Learn to code', 'Wear some plaid'];
var count = 0;

Array.observe(model, function(changeRecords) {
  count++;
  console.log('Array observe', changeRecords, count);
});

model[0] = 'Teach Paul Lewis to code';
model[1] = 'Channel your inner Paul Irish';
Observing arrays

Performance characteristics

Think of O.o()'s computational cost like a read cache. A cache is generally the right choice when:

  1. Reads dominate writes in frequency.
  2. A cache can trade constant write work for algorithmically better read performance.
  3. The constant-time write slowdown is acceptable.

O.o() is designed for case 1. Dirty-checking, by contrast, requires keeping a full copy of all observed data — a structural memory cost that O.o() avoids. Beyond that, dirty-checking is a leaky abstraction: it has to run whenever data may have changed, there's no robust way to schedule that, and polling intervals risk visual artifacts and race conditions. Dirty-checking also relies on a global registry of observers, creating memory-leak hazards and teardown costs O.o() sidesteps.

The benchmark below (available on GitHub) compares dirty-checking against O.o(), structured as graphs of observed-object-set-size versus number of mutations. The general result: dirty-checking performance is algorithmically proportional to the number of observed objects, while O.o() performance scales with the number of mutations made.

Dirty-checking

Dirty checking performance

Chrome with Object.observe() switched on

Observe performance

Polyfilling Object.observe()

Native O.o() is available in Chrome 36, but for other browsers Polymer's Observe-JS polyfill fills the gap. It uses the native implementation when present, and otherwise provides its own plus useful sugar. Two of its more powerful features:

  1. Path observation. You can observe a path like "foo.bar.baz" from a given object and get notified when the value at that path changes. If the path is unreachable, it counts as undefined.

Observing a value at a path:

var obj = { foo: { bar: 'baz' } };

var observer = new PathObserver(obj, 'foo.bar');
observer.open(function(newValue, oldValue) {
  // respond to obj.foo.bar having changed value.
});
  1. Array splice reporting. It reports array changes as the minimal set of splice operations needed to transform the old array into the new one — essentially the minimum work to move between states.

Reporting array changes as minimal splices:

var arr = [0, 1, 2, 4];

var observer = new ArrayObserver(arr);
observer.open(function(splices) {
  // respond to changes to the elements of arr.
  splices.forEach(function(splice) {
    splice.index; // index position that the change occurred.
    splice.removed; // an array of values representing the sequence of elements which were removed
    splice.addedCount; // the number of elements which were inserted.
  });
});

Framework adoption

The opportunity O.o() gives frameworks is substantial — native observation can directly improve data-binding performance in supporting browsers. Ember's Yehuda Katz and Erik Bryn confirmed support for O.o() is on Ember's near-term roadmap. Angular's Misko Hervy authored a 2.0 design doc describing improved change detection — its long-term approach is to use Object.observe() when available in Chrome stable, using Watchtower.js as their own change detection in the interim.

Conclusions

O.o() is a powerful platform addition you can use today. The hope is that more browsers adopt it, giving JavaScript frameworks native observation performance boosts. For now, it works in Chrome 36 and above, and should appear in a future Opera release. Talk to framework authors about how they plan to use Object.observe() to improve data-binding performance in your applications.

Resources

With thanks to Rafael Weinstein, Jake Archibald, Eric Bidelman, Paul Kinlan and Vivian Cromwell for their input and reviews.