Growing with the Web

Using the Intersection Observer web API to improve performance

Published
Tags:

xterm.js ran into a great problem to use the Intersection Observer web API. This post introduces the problem, what Intersection Observer is and why it’s such a good fit.

For the uninitiated, xterm.js is an open source project that I work on which enables the creation of terminal emulators for the web. VS Code for example uses xterm.js to drive it’s integrated terminal.

Problem

Some consumers of xterm.js spawn multiple terminal instances at once. Ever since the recent performance improvements which moved rendering over to use canvas, each terminal still consumes CPU/GPU time to render terminals even when they’re hidden. This is a particular problem if there are background tasks running like long running or watch scripts. Let’s dig a little deeper into solving this in a nice way.

Solution

In VS Code we know exactly when the terminal is being hidden and displayed so this problem could be solved by simply adding new APIs Terminal.pauseRenderer and Terminal.resumeRenderer. The issue with this approach though is that then every single consumer of xterm.js needs to trigger these new APIs at the right time in order to reap the benefits.

It turns out there’s an easier way using the relatively new Intersection Observer API. Intersection Observer works by observing an element within some viewport and fires a callback when a certain percentage of it is visible and when it drops below that percentage.

var options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 0.5
};
var observer = new IntersectionObserver(callback, options);
var target = document.querySelector('#item');
observer.observe(target);

function callback(entries) {
  // Fires when #item goes above or below 50% visible inside #scrollArea
  entries.forEach(entry => {
    console.log('intersection change', entry);
  });
}

The use case for xterm.js is relatively simple as that threshold is 0%; we want to fire when either any part of the terminal is visible or when the terminal is completely hidden from view. We also want to use the browser’s viewport so there’s no need to set root or rootMargin.

// Basic feature detection
if ('IntersectionObserver' in window) {
  // Create the IntersectionObserver and observe the terminal
  const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), {threshold: 0});
  observer.observe(this._terminal.element);
}

The actual change callback will pause or resume the rendering and force a full refresh of the terminal if needed.

public onIntersectionChange(entry: IntersectionObserverEntry): void {
    this._isPaused = entry.intersectionRatio === 0;
    if (!this._isPaused && this._needsFullRefresh) {
      this._terminal.refresh(0, this._terminal.rows - 1);
    }
  }
}

Whenever an operation that could change the terminal viewport occurs while paused, the flag this._needsFullRefresh is set to true so a full refresh can be done when the renderer is resumed.

private _runOperation(operation: (layer: IRenderLayer) => void): void {
  if (this._isPaused) {
    this._needsFullRefresh = true;
  } else {
    this._renderLayers.forEach(l => operation(l));
  }
}

And that’s it! Thanks to Intersection Observer, xterm.js can save some CPU/GPU cycles and battery with minimal effort and is now standard behavior which just works out of the box. If you’re interested in more details here is the pull request where the change was made.

Support

At the time of writing IntersectionObserver is supported by all major browser excluding Safari. There is also a polyfill available if you need better support.

Like this article?
Subscribe for more!