- Notifications
You must be signed in to change notification settings - Fork12
Context-aware web performance for everyone
License
csswizardry/Obs.js
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Meet your users where they are
Obs.js uses the Navigator and Battery APIs to get contextual information aboutyour users’ connection strength and battery status.
You can use this data to adapt your site/app to their environment, or beacon thedata 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 thewindow.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. --><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>
Obs.jsMUST be placed in an inline<script> tag in the<head> of yourdocument, 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 */;(()=>{conste=document.currentScript;if((!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");constt=document.documentElement,{connection:i}=navigator;window.obs=window.obs||{};consta=!0===(window.obs&&window.obs.config||{}).observeChanges,o=()=>{conste=window.obs||{},i="number"==typeofe.downlinkBucket?e.downlinkBucket:null;e.connectionCapability="low"===e.rttCategory&&null!=i&&i>=8?"strong":"high"===e.rttCategory||null!=i&&i<=5?"weak":"moderate";consta=!0===e.dataSaver||!0===e.batteryLow||!0===e.batteryCritical;e.conservationPreference=a?"conserve":"neutral";consto="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);consts=(e=>Number.isFinite(e)?25*Math.ceil(e/25):null)(a);null!=s&&(window.obs.rttBucket=s);constr=(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}`));constc=(l=n,Number.isFinite(l)?Math.ceil(l):null);varl;if(null!=c){window.obs.downlinkBucket=c;conste=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"ini&&(window.obs.downlinkMax=i.downlinkMax),o()};n(),a&&i&&"function"==typeofi.addEventListener&&i.addEventListener("change",n);consts=e=>{if(!e)return;const{level:i,charging:a}=e,n=Number.isFinite(i)?i<=.05:null;window.obs.batteryCritical=n;consts=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");constr=!!a;window.obs.batteryCharging=r,t.classList.toggle("has-battery-charging",r),o()};if("getBattery"innavigator&&navigator.getBattery().then(e=>{s(e),a&&"function"==typeofe.addEventListener&&(e.addEventListener("levelchange",()=>s(e)),e.addEventListener("chargingchange",()=>s(e)))}).catch(()=>{}),"deviceMemory"innavigator){conste=Number(navigator.deviceMemory),i=Number.isFinite(e)?e:null;window.obs.ramBucket=i;consta=(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}`))}varr;if("hardwareConcurrency"innavigator){conste=Number(navigator.hardwareConcurrency),i=Number.isFinite(e)?e:null;window.obs.cpuBucket=i;consta=(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}`))}(()=>{conste=window.obs||{},i=e.ramCategory,a=e.cpuCategory;leto="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 thelatest minifiedversion.
If you have long-lived pages or a single-page app, you can instruct Obs.js tolisten for changes to the connection and battery status by setting the followingconfig:
<script>window.obs={config:{observeChanges:true}}</script><script>// Obs.js</script>
The default isfalse, which means Obs.js will only run once on each page load.This is sufficient for most non-SPA sites.
The information provided by Obs.js is split into two categories:StatusesandStances.
- AStatus is a factual piece of information, such as whether the user hasenabled Data Saver, or whether their battery is charging, or if they are ona high latency connection.
- AStance is an opinion derived from Statuses. For example, if the user hasenabled Data Saver or their battery is low, we might say they haveaconservation preference of
conserve, meaning they might prefer to saveresources.
You can use either Statuses or Stances in your CSS or JavaScript.
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 (addedalongside.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'ordownlinkCategory === 'low' |
.has-connection-capability-moderate | Transport middling | Anything not strong/weak |
.has-connection-capability-strong | Transport looks strong | rttCategory === 'low'anddownlinkCategory === 'high' |
.has-conservation-preference-conserve | Frugality signal present | dataSaver === trueorbatteryLow === 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'ordataSaver === trueorbatteryCritical === true |
.has-delivery-mode-cautious | Be careful/middle weight | Otherwise (notrich/lite). E.g.batteryLow === true (withoutdataSaver/batteryCritical) orconnectionCapability === 'moderate'. |
.has-delivery-mode-rich | Allow rich/heavy media | connectionCapability === 'strong'anddataSaver !== trueandbatteryCritical !== true |
.has-ram-very-low | Very low RAM tier | ramCategory === 'very-low' (typicallyramBucket ≤ 1GB) |
.has-ram-low | Low RAM tier | ramCategory === 'low' (typicallyramBucket ≤ 2GB and > 1) |
.has-ram-medium | Medium RAM tier | ramCategory === 'medium' (typicallyramBucket ≤ 4GB and > 2) |
.has-ram-high | High RAM tier | ramCategory === 'high' (typicallyramBucket > 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'orramCategory is'very-low'/'low' |
.has-device-capability-moderate | Hardware middling | Anything not strong/weak |
.has-device-capability-strong | Hardware looks strong | cpuCategory === 'high'andramCategory is'medium'or'high' |
These classes are automatically added to the<html> element.
Obs.js also stores the following properties on thewindow.obs object:
| Property | Type | Meaning | Computed/derived from | Notes |
|---|---|---|---|---|
config.observeChanges | boolean | Attach change listeners | Defaultfalse; set by youbefore 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 toceil 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 toceil 1Mbps | navigator.connection.downlink | Thresholds:≤5,6–7,≥8 |
downlinkCategory | 'low' |'medium' |'high' | Bandwidth category | FromdownlinkBucket (≤ 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 | FromrttCategory +downlinkCategory (low/high signals) | Strong = low RTTand high BW; Weak = high RTTor low BW |
conservationPreference | 'conserve' |'neutral' | Frugality signal | dataSaver === trueorbatteryLow === true | — |
deliveryMode | 'rich' |'cautious' |'lite' | How ‘heavy’ you should go | FromconnectionCapability,dataSaver,batteryLow,batteryCritical | rich if strong and not (dataSaver orbatteryCritical);lite if weakordataSaverorbatteryCritical; elsecautious (e.g.batteryLow/moderate) |
canShowRichMedia | boolean | Convenience:deliveryMode !== 'lite' | Derived fromdeliveryMode | Shorthand for ‘go big’ |
shouldAvoidRichMedia | boolean | Convenience:deliveryMode === 'lite' | Derived fromdeliveryMode | Shorthand for ‘be frugal’ |
batteryCritical | boolean | null | Battery ≤ 5% | Battery API | true when battery level is ≤ 5%;alsobatteryLow === 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 | FromramBucket | Adds.has-ram-* classes |
cpuBucket | number (cores) | 1-core bucket (integer cores) | navigator.hardwareConcurrency | PrefercpuCategory 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 | FromramCategory andcpuCategory | strong when CPU ishighand RAM ismedium/high;weak when RAM isvery-low/lowor CPU islow; otherwisemoderate. Adds matching classes. |
Most of these APIs are only available in Chromium browsers. This means you needto decide how to handle notable absentees like iOS yourself: Obs.js does notmake opinionated decisions for you.
Your choices are:
- Always ship the rich version to Safari, or;
- Always ship the lite version to Safari.
You can write yourifs andelses 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
License
Code of conduct
Uh oh!
There was an error while loading.Please reload this page.
