Obs.js: context‑aware web performance for everyone

August 27, 2025 · View on GitHub

<img src=./demo/assets/logo.png alt="Obs.js" width=330 height=107>

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:

ClassMeaningComputed/derived from
.has-data-saverUser enabled Data Savernavigator.connection.saveData === true
.has-battery-criticalBattery ≤ 5%battery.level ≤ 0.05 (added alongside .has-battery-low)
.has-battery-lowBattery ≤ 20%battery.level ≤ 0.2
.has-battery-chargingOn chargebattery.charging === true
.has-latency-lowLow RTTrtt < 75ms
.has-latency-mediumMedium RTT75–275ms
.has-latency-highHigh RTT> 275ms
.has-bandwidth-lowLow estimated bandwidthdownlinkCategory === 'low' (i.e. downlinkBucket ≤ 5Mbps)
.has-bandwidth-mediumMid estimated bandwidthdownlinkCategory === 'medium' (i.e. downlinkBucket 6–7Mbps)
.has-bandwidth-highHigh estimated bandwidthdownlinkCategory === 'high' (i.e. downlinkBucket ≥ 8Mbps)
.has-connection-capability-weakTransport looks weakrttCategory === 'high' or downlinkCategory === 'low'
.has-connection-capability-moderateTransport middlingAnything not strong/weak
.has-connection-capability-strongTransport looks strongrttCategory === 'low' and downlinkCategory === 'high'
.has-conservation-preference-conserveFrugality signal presentdataSaver === true or batteryLow === true
.has-conservation-preference-neutralNo frugality signalBattery isn’t low and Data Saver is not enabled
.has-delivery-mode-liteBe frugal/lightweightconnectionCapability === 'weak' or dataSaver === true or batteryCritical === true
.has-delivery-mode-cautiousBe careful/middle weightOtherwise (not rich/lite). E.g. batteryLow === true (without dataSaver/batteryCritical) or connectionCapability === 'moderate'.
.has-delivery-mode-richAllow rich/heavy mediaconnectionCapability === 'strong' and dataSaver !== true and batteryCritical !== true
.has-ram-very-lowVery low RAM tierramCategory === 'very-low' (typically ramBucket ≤ 1GB)
.has-ram-lowLow RAM tierramCategory === 'low' (typically ramBucket ≤ 2GB and > 1)
.has-ram-mediumMedium RAM tierramCategory === 'medium' (typically ramBucket ≤ 4GB and > 2)
.has-ram-highHigh RAM tierramCategory === 'high' (typically ramBucket > 4GB)
.has-cpu-lowFew logical corescpuCategory === 'low' (≤ 2 cores)
.has-cpu-mediumModerate logical corescpuCategory === 'medium' (3–5 cores)
.has-cpu-highMany logical corescpuCategory === 'high' (≥ 6 cores)
.has-device-capability-weakHardware looks weakcpuCategory === 'low' or ramCategory is 'very-low'/'low'
.has-device-capability-moderateHardware middlingAnything not strong/weak
.has-device-capability-strongHardware looks strongcpuCategory === '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:

PropertyTypeMeaningComputed/derived fromNotes
config.observeChangesbooleanAttach change listenersDefault false; set by you before Obs.js runsOpt-in for SPAs or long-lived pages
dataSaverbooleanUser enabled Data Savernavigator.connection.saveData
rttBucketnumber (ms)RTT bucketed to ceil 25msnavigator.connection.rttUndefined if Connection API missing
rttCategory'low' | 'medium' | 'high'CrUX tri-binDerived from RTT (<75, 75–275, >275)Drives latency classes
downlinkBucketnumber (Mbps)Downlink bucketed to ceil 1Mbpsnavigator.connection.downlinkThresholds: ≤5, 6–7, ≥8
downlinkCategory'low' | 'medium' | 'high'Bandwidth categoryFrom downlinkBucket (≤ 5 → low, 6–7 → medium, ≥ 8 → high)Mirrors .has-bandwidth-* classes
downlinkMaxnumber (Mbps)Max estimated downlink (if exposed)navigator.connection.downlinkMaxInformational only
connectionCapability'strong' | 'moderate' | 'weak'Transport assessmentFrom rttCategory + downlinkCategory (low/high signals)Strong = low RTT and high BW; Weak = high RTT or low BW
conservationPreference'conserve' | 'neutral'Frugality signaldataSaver === true or batteryLow === true
deliveryMode'rich' | 'cautious' | 'lite'How ‘heavy’ you should goFrom connectionCapability, dataSaver, batteryLow, batteryCriticalrich if strong and not (dataSaver or batteryCritical); lite if weak or dataSaver or batteryCritical; else cautious (e.g. batteryLow/moderate)
canShowRichMediabooleanConvenience: deliveryMode !== 'lite'Derived from deliveryModeShorthand for ‘go big’
shouldAvoidRichMediabooleanConvenience: deliveryMode === 'lite'Derived from deliveryModeShorthand for ‘be frugal’
batteryCriticalboolean | nullBattery ≤ 5%Battery APItrue when battery level is ≤ 5%; also batteryLow === true
batteryLowboolean | nullBattery ≤ 20%Battery APItrue when battery level is ≤ 20%; null if unknown
batteryChargingboolean | nullOn chargeBattery APInull if unknown
ramBucketnumber (GB)Coarse device RAM bucketnavigator.deviceMemory (UA-rounded)Typical values: 0.5, 1, 2, 4, 8
ramCategory'very-low' | 'low' | 'medium' | 'high'RAM tierFrom ramBucketAdds .has-ram-* classes
cpuBucketnumber (cores)1-core bucket (integer cores)navigator.hardwareConcurrencyPrefer cpuCategory for segmentation
cpuCategory'low' | 'medium' | 'high'CPU tierFrom cores (≤ 2 = low, 3–5 = medium, ≥ 6 = high)Adds .has-cpu-* classes
deviceCapability'strong' | 'moderate' | 'weak'Device capability stanceFrom ramCategory and cpuCategorystrong 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.