Using the Intersection Observer web API to improve performance
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.