Skip to content

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.:

<htmlclass="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-litebody {
background-image:url('lo-res.jpg');
}

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

{"config": {"adaptive": true,"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. --><linkrel=preloadas=imagehref=poster.jpg><divclass=media-placeholderstyle="background-image: url(poster.jpg);"><script>constmediaPlaceholder=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.constv=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.constimg=newImage();img.src='poster.jpg';img.alt='';mediaPlaceholder.replaceChildren(img);}</script></div>

Installation

There are two main options for installing Obs.js depending on whether you want fully adaptive mode, or analytics-only mode.

  1. Adaptive: Must run early and inline; adds classes to <html> for CSS/JS adaptation later on.
  2. Analytics: Can be deferred/external; does not add classes to <html>, but still populates window.obs for analytics purposes.

Adaptive Installation

If you are using Obs.js for adaptation, it 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 | (c) Harry Roberts, csswizardry.com | MIT */;(()=>{conste=document.currentScript,t=window.obs,i=null!==t&&"object"==typeoft&&t.config||{},n=!1!==i.adaptive;if(n&&(!e||e.src||e.type&&"module"===e.type.toLowerCase())&&!1===/^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname))returnvoidconsole.warn("[Obs.js] Skipping: must be an inline, classic <script> in <head>.",e?e.src?"src="+e.src:"type="+e.type:"type=module");consta=null!==t&&"object"==typeoft?t:{};window.obs=a;constr=!0===i.observeChanges,o=["low","medium","high"],l=["low","medium","high"],c=["very-low","low","medium","high"],d=["low","medium","high"],s=["strong","moderate","weak"],u=["strong","moderate","weak"],g=["conserve","neutral"],h=["rich","cautious","lite"];lety,m,v=!1;constw=(e,t,i)=>{n&&(t.forEach(t=>y.classList.remove(`has-${e}-${t}`)),null!==i&&y.classList.add(`has-${e}-${i}`))},b=(e,t)=>{n&&y.classList.toggle(e,t)},p=()=>{a.connectionCapability="low"===a.rttCategory&&"high"===a.downlinkCategory?"strong":"high"===a.rttCategory||"low"===a.downlinkCategory?"weak":"moderate";conste=!0===a.dataSaver||!0===a.batteryLow||!0===a.batteryCritical;a.conservationPreference=e?"conserve":"neutral",a.deliveryMode="weak"===a.connectionCapability||!0===a.dataSaver||!0===a.batteryCritical?"lite":"strong"!==a.connectionCapability||e?"cautious":"rich",a.canShowRichMedia="lite"!==a.deliveryMode,a.shouldAvoidRichMedia="lite"===a.deliveryMode,w("connection-capability",u,a.connectionCapability),w("conservation-preference",g,a.conservationPreference),w("delivery-mode",h,a.deliveryMode)},C=()=>{if(!m)return;const{saveData:e,rtt:t,downlink:i}=m;a.dataSaver=!!e,b("has-data-saver",a.dataSaver);constn=(e=>Number.isFinite(e)?25*Math.ceil(e/25):null)(t),r=(e=>Number.isFinite(e)?e<75?"low":e<275?"medium":"high":null)(t);null===n?(deletea.rttBucket,deletea.rttCategory):(a.rttBucket=n,a.rttCategory=r),w("latency",o,r);constc=(e=>Number.isFinite(e)?Math.ceil(e):null)(i),d=(e=>null===e?null:e<=5?"low":e>=8?"high":"medium")(c);null===c?(deletea.downlinkBucket,deletea.downlinkCategory):(a.downlinkBucket=c,a.downlinkCategory=d),w("bandwidth",l,d),"downlinkMax"inm&&(a.downlinkMax=m.downlinkMax),p()},k=()=>{v||(v=!0,y=document.documentElement,m=navigator.connection,C(),r&&m&&"function"==typeofm.addEventListener&&m.addEventListener("change",C),"getBattery"innavigator&&navigator.getBattery().then(e=>{constt=()=>(e=>{if(!e)return;const{level:t,charging:i}=e,r=Number.isFinite(t)?t<=.05:null,o=Number.isFinite(t)?t<=.2:null;a.batteryCritical=r,a.batteryLow=o,w("battery",["critical","low"],null),n&&o&&y.classList.add("has-battery-low"),n&&r&&y.classList.add("has-battery-critical"),a.batteryCharging=!!i,b("has-battery-charging",a.batteryCharging),p()})(e);t(),r&&"function"==typeofe.addEventListener&&(e.addEventListener("levelchange",t),e.addEventListener("chargingchange",t))}).catch(()=>{}),(()=>{if("deviceMemory"innavigator){conste=Number(navigator.deviceMemory),t=Number.isFinite(e)?e:null,i=(e=>Number.isFinite(e)?e<=1?"very-low":e<=2?"low":e<=4?"medium":"high":null)(t);a.ramBucket=t,null===i?deletea.ramCategory:a.ramCategory=i,w("ram",c,i)}if("hardwareConcurrency"innavigator){conste=Number(navigator.hardwareConcurrency),t=Number.isFinite(e)?e:null,i=(e=>Number.isFinite(e)?e<=2?"low":e<=5?"medium":"high":null)(t);a.cpuBucket=t,null===i?deletea.cpuCategory:a.cpuCategory=i,w("cpu",d,i)}})(),(()=>{conste=a.ramCategory,t=a.cpuCategory;leti="moderate";"medium"!==e&&"high"!==e||"high"!==t?("very-low"===e||"low"===e||"low"===t)&&(i="weak"):i="strong",a.deviceCapability=i,w("device-capability",s,i)})())};if(!0===document.prerendering){conste=()=>{document.removeEventListener("prerenderingchange",e),document.removeEventListener("visibilitychange",t),k()},t=()=>{"visible"===document.visibilityState&&e()};document.addEventListener("prerenderingchange",e,{once:!0}),document.addEventListener("visibilitychange",t)}elsek()})();//# sourceURL=obs.inline.js</script>

Or download the latest minified version.

On a prerendered page, Obs.js waits for activation before it reads browser signals or mutates the <html> element. It then starts once, using the same configuration and lifecycle as a normal navigation.

Analytics Installation

If you only want to collect signals for analytics, disable adaptive mode before Obs.js runs:

<script>window.obs={config: {adaptive: false}};</script><scriptsrc="/path/to/obs.js" defer></script><scriptsrc="/path/to/obs-speedcurve.js" defer></script>

With adaptive: false, Obs.js still populates window.obs, but it will not add classes to the <html> element or require the strict inline-in-<head> installation pattern.

The optional SpeedCurve adapter records the Obs.js snapshot once when it runs. Battery properties may be absent because the Battery Status API resolves asynchronously; the adapter does not wait, poll, or send a later update.

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 RTT>= 75ms and < 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'ordownlinkCategory === 'low'
.has-connection-capability-moderateTransport middlingAnything not strong/weak
.has-connection-capability-strongTransport looks strongrttCategory === 'low'anddownlinkCategory === 'high'
.has-conservation-preference-conserveFrugality signal presentdataSaver === trueorbatteryLow === true
.has-conservation-preference-neutralNo frugality signalBattery isn’t low and Data Saver is not enabled
.has-delivery-mode-liteBe frugal/lightweightconnectionCapability === 'weak'ordataSaver === trueorbatteryCritical === 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'anddataSaver !== trueandbatteryCritical !== 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'orramCategory is 'very-low'/'low'
.has-device-capability-moderateHardware middlingAnything not strong/weak
.has-device-capability-strongHardware looks strongcpuCategory === 'high'andramCategory 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.adaptivebooleanEnable adaptive HTML classesDefault true; set by you before Obs.js runsSet to false for analytics-only usage: Obs.js still populates window.obs, but it won’t mutate <html> or require inline <head> installation
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 and <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 === trueorbatteryLow === true
deliveryMode'rich' | 'cautious' | 'lite'How ‘heavy’ you should goFrom connectionCapability, dataSaver, batteryLow, batteryCriticalrich if strong and not (dataSaver or batteryCritical); lite if weak ordataSaverorbatteryCritical; 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%; alsobatteryLow === 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 highand RAM is medium/high; weak when RAM is very-low/lowor 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.

About

Context-aware web performance for everyone

Topics

Resources

Code of conduct

Stars

705 stars

Watchers

4 watching

Forks

Releases

Contributors

Languages