Skip to content

csswizardry/Obs.js

Repository files navigation

Obs.js

Obs.js: context‑aware web performance for everyone

Meet your users where they are

Obs.js uses the Navigator and Battery APIs to get contextual information about your users’ connection strength and battery status.

You can use this data to adapt your site/app to their environment, or beacon the data off to an analytics endpoint.

At its simplest, Obs.js will add a suite of classes to your <html> element, e.g.:

<html class="has-latency-low
             has-bandwidth-high
             has-battery-charging
             has-connection-capability-strong
             has-conservation-preference-neutral
             has-delivery-mode-rich">

This means you could do something like this:

/**
 * Disable all animations and transitions if a user’s battery is below 5%.
 */
.has-battery-critical,
.has-battery-critical * {
  animation: none;
  transition: none;
}

Or this:

body {
  background-image: url('hi-res.jpg');
}

/**
 * Show low-resolution images if the user can’t take rich media right now.
 */
.has-delivery-mode-lite body {
  background-image: url('lo-res.jpg');
}

It also exposes this, and more, information via the window.obs object:

{
  "config": {
    "observeChanges": false
  },
  "dataSaver": false,
  "rttBucket": 50,
  "rttCategory": "low",
  "downlinkBucket": 10,
  "connectionCapability": "strong",
  "conservationPreference": "neutral",
  "deliveryMode": "rich",
  "canShowRichMedia": true,
  "shouldAvoidRichMedia": false,
  "batteryCritical": false,
  "batteryLow": false,
  "batteryCharging": true
}

This means you could do something like this:

<!--
  - Fetch low-resolution poster/placeholder image regardless.
  -->
<link rel=preload as=image href=poster.jpg>

<div class=media-placeholder style="background-image: url(poster.jpg);">

  <script>

    const mediaPlaceholder = document.querySelector('.media-placeholder');

    if (window.obs && window.obs.canShowRichMedia) {
      // If we can show rich media, load the video with the poster image in place.
      const v = document.createElement('video');
      v.src = 'video.mp4';
      v.poster = 'poster.jpg';
      v.autoplay = true;
      v.muted = true;
      v.playsInline = true;
      v.setAttribute('controls', '');
      mediaPlaceholder.replaceChildren(v);
    } else {
      // If not, just show the poster image as an image element.
      const img = new Image();
      img.src = 'poster.jpg';
      img.alt = '';
      mediaPlaceholder.replaceChildren(img);
    }

  </script>

</div>

Installation

Obs.js MUST be placed in an inline <script> tag in the <head> of your document, before any other scripts, stylesheets, or HTML that may depend on it.

Copy/paste the following as close to the top of your <head> as possible:

<script>
  /*! Obs.js 0.2.1 | (c) Harry Roberts, csswizardry.com | MIT */
  ;(()=>{const e=document.currentScript;if((!e||e.src||e.type&&"module"===e.type.toLowerCase())&&!1===/^(localhost|127\.0\.0\.1|::1)$/.test(location.hostname))return void console.warn("[Obs.js] Skipping: must be an inline, classic <script> in <head>.",e?e.src?"src="+e.src:"type="+e.type:"type=module");const t=document.documentElement,{connection:i}=navigator;window.obs=window.obs||{};const a=!0===(window.obs&&window.obs.config||{}).observeChanges,o=()=>{const e=window.obs||{},i="number"==typeof e.downlinkBucket?e.downlinkBucket:null;e.connectionCapability="low"===e.rttCategory&&null!=i&&i>=8?"strong":"high"===e.rttCategory||null!=i&&i<=5?"weak":"moderate";const a=!0===e.dataSaver||!0===e.batteryLow||!0===e.batteryCritical;e.conservationPreference=a?"conserve":"neutral";const o="weak"===e.connectionCapability||!0===e.dataSaver||!0===e.batteryCritical;e.deliveryMode="strong"!==e.connectionCapability||o||a?o?"lite":"cautious":"rich",e.canShowRichMedia="lite"!==e.deliveryMode,e.shouldAvoidRichMedia="lite"===e.deliveryMode,["strong","moderate","weak"].forEach(e=>{t.classList.remove(`has-connection-capability-${e}`)}),t.classList.add(`has-connection-capability-${e.connectionCapability}`),["conserve","neutral"].forEach(e=>{t.classList.remove(`has-conservation-preference-${e}`)}),t.classList.add(`has-conservation-preference-${e.conservationPreference}`),["rich","cautious","lite"].forEach(e=>{t.classList.remove(`has-delivery-mode-${e}`)}),t.classList.add(`has-delivery-mode-${e.deliveryMode}`)},n=()=>{if(!i)return;const{saveData:e,rtt:a,downlink:n}=i;window.obs.dataSaver=!!e,t.classList.toggle("has-data-saver",!!e);const s=(e=>Number.isFinite(e)?25*Math.ceil(e/25):null)(a);null!=s&&(window.obs.rttBucket=s);const r=(e=>Number.isFinite(e)?e<75?"low":e<=275?"medium":"high":null)(a);r&&(window.obs.rttCategory=r,["low","medium","high"].forEach(e=>t.classList.remove(`has-latency-${e}`)),t.classList.add(`has-latency-${r}`));const c=(l=n,Number.isFinite(l)?Math.ceil(l):null);var l;if(null!=c){window.obs.downlinkBucket=c;const e=c<=5?"low":c>=8?"high":"medium";window.obs.downlinkCategory=e,["low","medium","high"].forEach(e=>t.classList.remove(`has-bandwidth-${e}`)),t.classList.add(`has-bandwidth-${e}`)}"downlinkMax"in i&&(window.obs.downlinkMax=i.downlinkMax),o()};n(),a&&i&&"function"==typeof i.addEventListener&&i.addEventListener("change",n);const s=e=>{if(!e)return;const{level:i,charging:a}=e,n=Number.isFinite(i)?i<=.05:null;window.obs.batteryCritical=n;const s=Number.isFinite(i)?i<=.2:null;window.obs.batteryLow=s,["critical","low"].forEach(e=>t.classList.remove(`has-battery-${e}`)),s&&t.classList.add("has-battery-low"),n&&t.classList.add("has-battery-critical");const r=!!a;window.obs.batteryCharging=r,t.classList.toggle("has-battery-charging",r),o()};if("getBattery"in navigator&&navigator.getBattery().then(e=>{s(e),a&&"function"==typeof e.addEventListener&&(e.addEventListener("levelchange",()=>s(e)),e.addEventListener("chargingchange",()=>s(e)))}).catch(()=>{}),"deviceMemory"in navigator){const e=Number(navigator.deviceMemory),i=Number.isFinite(e)?e:null;window.obs.ramBucket=i;const a=(r=i,Number.isFinite(r)?r<=1?"very-low":r<=2?"low":r<=4?"medium":"high":null);a&&(window.obs.ramCategory=a,["very-low","low","medium","high"].forEach(e=>t.classList.remove(`has-ram-${e}`)),t.classList.add(`has-ram-${a}`))}var r;if("hardwareConcurrency"in navigator){const e=Number(navigator.hardwareConcurrency),i=Number.isFinite(e)?e:null;window.obs.cpuBucket=i;const a=(e=>Number.isFinite(e)?e<=2?"low":e<=5?"medium":"high":null)(i);a&&(window.obs.cpuCategory=a,["low","medium","high"].forEach(e=>t.classList.remove(`has-cpu-${e}`)),t.classList.add(`has-cpu-${a}`))}(()=>{const e=window.obs||{},i=e.ramCategory,a=e.cpuCategory;let o="moderate";"high"!==i&&"medium"!==i||"high"!==a?("very-low"===i||"low"===i||"low"===a)&&(o="weak"):o="strong",e.deviceCapability=o,["strong","moderate","weak"].forEach(e=>{t.classList.remove(`has-device-capability-${e}`)}),t.classList.add(`has-device-capability-${o}`)})()})();
  //# sourceURL=obs.inline.js
</script>

Or download the latest minified version.

Listen for Changes

If you have long-lived pages or a single-page app, you can instruct Obs.js to listen for changes to the connection and battery status by setting the following config:

<script>window.obs = { config: { observeChanges: true } }</script>

<script>
  // Obs.js
</script>

The default is false, which means Obs.js will only run once on each page load. This is sufficient for most non-SPA sites.

Statuses and Stances

The information provided by Obs.js is split into two categories: Statuses and Stances.

  • A Status is a factual piece of information, such as whether the user has enabled Data Saver, or whether their battery is charging, or if they are on a high latency connection.
  • A Stance is an opinion derived from Statuses. For example, if the user has enabled Data Saver or their battery is low, we might say they have a conservation preference of conserve, meaning they might prefer to save resources.

You can use either Statuses or Stances in your CSS or JavaScript.

Available CSS Classes and JS Properties

Obs.js exposes the following classes under the following conditions:

Class Meaning Computed/derived from
.has-data-saver User enabled Data Saver navigator.connection.saveData === true
.has-battery-critical Battery ≤ 5% battery.level ≤ 0.05 (added alongside .has-battery-low)
.has-battery-low Battery ≤ 20% battery.level ≤ 0.2
.has-battery-charging On charge battery.charging === true
.has-latency-low Low RTT rtt < 75ms
.has-latency-medium Medium RTT 75–275ms
.has-latency-high High RTT > 275ms
.has-bandwidth-low Low estimated bandwidth downlinkCategory === 'low' (i.e. downlinkBucket ≤ 5Mbps)
.has-bandwidth-medium Mid estimated bandwidth downlinkCategory === 'medium' (i.e. downlinkBucket 6–7Mbps)
.has-bandwidth-high High estimated bandwidth downlinkCategory === 'high' (i.e. downlinkBucket ≥ 8Mbps)
.has-connection-capability-weak Transport looks weak rttCategory === 'high' or downlinkCategory === 'low'
.has-connection-capability-moderate Transport middling Anything not strong/weak
.has-connection-capability-strong Transport looks strong rttCategory === 'low' and downlinkCategory === 'high'
.has-conservation-preference-conserve Frugality signal present dataSaver === true or batteryLow === true
.has-conservation-preference-neutral No frugality signal Battery isn’t low and Data Saver is not enabled
.has-delivery-mode-lite Be frugal/lightweight connectionCapability === 'weak' or dataSaver === true or batteryCritical === true
.has-delivery-mode-cautious Be careful/middle weight Otherwise (not rich/lite). E.g. batteryLow === true (without dataSaver/batteryCritical) or connectionCapability === 'moderate'.
.has-delivery-mode-rich Allow rich/heavy media connectionCapability === 'strong' and dataSaver !== true and batteryCritical !== true
.has-ram-very-low Very low RAM tier ramCategory === 'very-low' (typically ramBucket ≤ 1GB)
.has-ram-low Low RAM tier ramCategory === 'low' (typically ramBucket ≤ 2GB and > 1)
.has-ram-medium Medium RAM tier ramCategory === 'medium' (typically ramBucket ≤ 4GB and > 2)
.has-ram-high High RAM tier ramCategory === 'high' (typically ramBucket > 4GB)
.has-cpu-low Few logical cores cpuCategory === 'low' (≤ 2 cores)
.has-cpu-medium Moderate logical cores cpuCategory === 'medium' (3–5 cores)
.has-cpu-high Many logical cores cpuCategory === 'high' (≥ 6 cores)
.has-device-capability-weak Hardware looks weak cpuCategory === 'low' or ramCategory is 'very-low'/'low'
.has-device-capability-moderate Hardware middling Anything not strong/weak
.has-device-capability-strong Hardware looks strong cpuCategory === 'high' and ramCategory is 'medium' or 'high'

These classes are automatically added to the <html> element.

Obs.js also stores the following properties on the window.obs object:

Property Type Meaning Computed/derived from Notes
config.observeChanges boolean Attach change listeners Default false; set by you before Obs.js runs Opt-in for SPAs or long-lived pages
dataSaver boolean User enabled Data Saver navigator.connection.saveData
rttBucket number (ms) RTT bucketed to ceil 25ms navigator.connection.rtt Undefined if Connection API missing
rttCategory 'low' | 'medium' | 'high' CrUX tri-bin Derived from RTT (<75, 75–275, >275) Drives latency classes
downlinkBucket number (Mbps) Downlink bucketed to ceil 1Mbps navigator.connection.downlink Thresholds: ≤5, 6–7, ≥8
downlinkCategory 'low' | 'medium' | 'high' Bandwidth category From downlinkBucket (≤ 5 → low, 6–7 → medium, ≥ 8 → high) Mirrors .has-bandwidth-* classes
downlinkMax number (Mbps) Max estimated downlink (if exposed) navigator.connection.downlinkMax Informational only
connectionCapability 'strong' | 'moderate' | 'weak' Transport assessment From rttCategory + downlinkCategory (low/high signals) Strong = low RTT and high BW; Weak = high RTT or low BW
conservationPreference 'conserve' | 'neutral' Frugality signal dataSaver === true or batteryLow === true
deliveryMode 'rich' | 'cautious' | 'lite' How ‘heavy’ you should go From connectionCapability, dataSaver, batteryLow, batteryCritical rich if strong and not (dataSaver or batteryCritical); lite if weak or dataSaver or batteryCritical; else cautious (e.g. batteryLow/moderate)
canShowRichMedia boolean Convenience: deliveryMode !== 'lite' Derived from deliveryMode Shorthand for ‘go big’
shouldAvoidRichMedia boolean Convenience: deliveryMode === 'lite' Derived from deliveryMode Shorthand for ‘be frugal’
batteryCritical boolean | null Battery ≤ 5% Battery API true when battery level is ≤ 5%; also batteryLow === true
batteryLow boolean | null Battery ≤ 20% Battery API true when battery level is ≤ 20%; null if unknown
batteryCharging boolean | null On charge Battery API null if unknown
ramBucket number (GB) Coarse device RAM bucket navigator.deviceMemory (UA-rounded) Typical values: 0.5, 1, 2, 4, 8
ramCategory 'very-low' | 'low' | 'medium' | 'high' RAM tier From ramBucket Adds .has-ram-* classes
cpuBucket number (cores) 1-core bucket (integer cores) navigator.hardwareConcurrency Prefer cpuCategory for segmentation
cpuCategory 'low' | 'medium' | 'high' CPU tier From cores (≤ 2 = low, 3–5 = medium, ≥ 6 = high) Adds .has-cpu-* classes
deviceCapability 'strong' | 'moderate' | 'weak' Device capability stance From ramCategory and cpuCategory strong when CPU is high and RAM is medium/high; weak when RAM is very-low/low or CPU is low; otherwise moderate. Adds matching classes.

Unsupported Browsers

Most of these APIs are only available in Chromium browsers. This means you need to decide how to handle notable absentees like iOS yourself: Obs.js does not make opinionated decisions for you.

Your choices are:

  1. Always ship the rich version to Safari, or;
  2. Always ship the lite version to Safari.

You can write your ifs and elses to accommodate either.

if (window.obs?.shouldAvoidRichMedia === true) {
  // Serve lite version to slow supportive browsers.
} else {
  // Serve rich version to fast supportive browsers and Safari.
}
if (window.obs?.canShowRichMedia === true) {
  // Serve rich version to fast supportive browsers.
} else {
  // Serve lite version to slow supportive browsers and Safari.
}

The choice is yours.