The same web page can feel like two completely different products. On a fast laptop with a full battery it is instant and luxurious. On a budget Android phone over flaky 3G with four percent of charge left, that same page is a slideshow that drains what little juice remains. Most sites ship one experience to everyone and hope for the best. Obs.js asks a sharper question: not "can we ship this?" but "should we ship this to this visitor, under these conditions, right now?"
Obs.js is a tiny, dependency-free vanilla-JavaScript script by web-performance consultant Harry Roberts of CSS Wizardry. You drop it inline into the <head>, and it quietly reads a handful of browser signals — connection quality, device memory, CPU cores, battery level, Data Saver — and translates them into two things: a suite of CSS classes on the <html> element, and a structured window.obs object. From there you adapt the experience with plain CSS or a few lines of JavaScript. It does not fix anyone's hardware or network. It just gives you the information to make smarter delivery decisions, and it does so with essentially zero runtime cost.
A note before we go further: this is not a React library, and it is not on npm. But if you build performance-conscious apps, it is very much worth your attention, and it pairs nicely with React, as we will see.
Status and Stance: How It Thinks
Obs.js draws a clean line between two kinds of information.
Status is observable fact. Is the battery charging? Is Data Saver enabled? What is the round-trip time? These are signals the browser reports, taken more or less at face value.
Stance is computed opinion. Should this visitor get rich media or a lite experience? Is this device broadly capable or constrained? Is the connection strong, moderate, or weak? Obs.js combines several raw signals into these higher-level recommendations so you do not have to write the threshold logic yourself.
The reason this distinction matters is reliability. Raw values like downlink and rtt from the Network Information API are estimates, not guarantees, and they can be jittery. Instead of trusting a precise number, Obs.js buckets values into coarse categories. A connection is not "7.3 Mbps," it is simply "high bandwidth." That coarseness is a feature: it absorbs the noise and gives you stable, actionable signals.
Under the hood it reads four browser APIs:
- Network Information API (
navigator.connection) forrtt,downlink,downlinkMax, andsaveData - Device Memory API (
navigator.deviceMemory) for RAM in gigabytes - Hardware Concurrency API (
navigator.hardwareConcurrency) for CPU core count - Battery Status API (
navigator.getBattery()) for charge level and charging state
Dropping It In
Here is the part that surprises people. Obs.js is not installed from a package manager, and it will actively refuse to run if you try. It must be an inline, classic <script> in the <head> — not a module, not deferred, not an external src in adaptive mode. If it detects that it has been loaded the wrong way, it self-terminates with a console warning and does nothing.
This sounds like an inconvenience until you understand why. Obs.js has to run before any HTML, CSS, or JavaScript that depends on its output. If the classes are not on <html> before the browser starts painting, your adaptive CSS arrives too late and you get a flash of the wrong experience. Running first, synchronously, in the head is the whole point. It even needs to beat tools like Google Tag Manager.
So instead of npm install, you paste the minified script directly:
<head>
<script>
/*! Obs.js | (c) Harry Roberts, csswizardry.com | MIT */
;(()=>{ /* ...minified Obs.js IIFE... */ })();
</script>
<!-- the rest of your head -->
</head>
Once it runs, your root element ends up looking something like this:
<html class="has-latency-low
has-bandwidth-high
has-battery-charging
has-connection-capability-strong
has-conservation-preference-neutral
has-delivery-mode-rich">
Roughly thirty classes are available across families like latency, bandwidth, connection capability, battery, conservation preference, delivery mode, device capability, RAM, CPU, and Data Saver. You only style the ones you care about.
Adapting With Nothing But CSS
The headline trick is that you can build genuinely adaptive UX without writing a single line of consumer JavaScript. The classes are on <html>, so plain CSS selectors do the work.
Want to stop all motion when the battery is critically low, at or below five percent? One rule:
.has-battery-critical,
.has-battery-critical * {
animation: none;
transition: none;
}
Want to serve a high-resolution background image to capable visitors but a lightweight one to anyone in lite delivery mode?
body {
background-image: url('hi-res.jpg');
}
.has-delivery-mode-lite body {
background-image: url('lo-res.jpg');
}
This is the angle that makes Obs.js so appealing to a CSS-minded audience. The mechanism is just cascade and specificity. You can drop web fonts on weak connections, simplify navigation on a draining battery, or lower media quality under caution, all in your stylesheet, all without touching your JavaScript bundle.
Reading the Signals From JavaScript
When you need programmatic decisions, the window.obs object carries everything. The two properties you will reach for most are the composite opinions:
if (window.obs?.shouldAvoidRichMedia === true) {
// serve the lite version: slow but supported browsers
} else {
// serve the rich version: fast browsers, plus Safari, which
// reports few signals and defaults to capable Apple hardware
}
Beyond those, window.obs exposes the granular values too: dataSaver, rttCategory, downlinkCategory, connectionCapability, conservationPreference, deliveryMode, canShowRichMedia, the battery flags batteryCritical / batteryLow / batteryCharging, plus ramCategory, cpuCategory, and the combined deviceCapability. Each computed category is backed by transparent thresholds. RTT counts as low under 75ms and high above 275ms; downlink is high at 8 Mbps or more; a device is CPU-low at two cores or fewer; battery is critical at five percent and low at twenty. Coarse, deliberate, and easy to reason about.
Wiring It Into React
In a React app, the simplest approach is to read window.obs once on mount and feed it into your component logic. A small hook keeps it tidy.
import { useState, useEffect } from "react";
type DeliveryMode = "rich" | "cautious" | "lite";
function useDeliveryMode(): DeliveryMode {
const [mode, setMode] = useState<DeliveryMode>(
() => (typeof window !== "undefined" && window.obs?.deliveryMode) || "rich"
);
useEffect(() => {
if (window.obs?.deliveryMode) {
setMode(window.obs.deliveryMode);
}
}, []);
return mode;
}
function Hero() {
const mode = useDeliveryMode();
const src = mode === "lite" ? "/hero-placeholder.jpg" : "/hero-full.jpg";
return (
<img
src={src}
alt="Article masthead"
loading={mode === "rich" ? "eager" : "lazy"}
/>
);
}
Notice the default of "rich". On Safari, iOS, and Firefox, most of these signals are simply unavailable, so window.obs reports little. Defaulting unsupported browsers to the rich path is the author's recommendation, on the reasoning that Safari users tend to be on capable Apple hardware. Obs.js is progressive enhancement: it sharpens delivery where the data exists, and gracefully gets out of the way where it does not.
Long-Lived Pages and Telemetry
Two configuration options unlock more advanced behavior, both set by defining window.obs before the script runs.
For single-page apps and long-lived sessions, set observeChanges: true. By default Obs.js evaluates once at load, which is fine for a traditional page that will be replaced on navigation. But an SPA can live for hours while the user wanders from office WiFi onto a train. With change observation enabled, Obs.js re-evaluates as the connection or battery shifts, keeping your classes and window.obs honest throughout the session.
The second option turns Obs.js into a pure measurement tool. Set adaptive: false and it stops mutating the DOM entirely, populating window.obs for telemetry without touching <html>:
<script>window.obs = { config: { adaptive: false } };</script>
<script src="/path/to/obs.js"></script>
This analytics-only mode, added in version 0.3.0, is quietly powerful. Feed the computed categories into your analytics as custom dimensions and you can finally segment real users by the conditions they actually faced. Harry Roberts did exactly this on his own site and found something counterintuitive: conversion was higher among low-battery visitors, apparently because a dying battery creates urgency. You cannot learn that from a synthetic lab test on a fully charged device.
Does Lite Actually Cost You Anything?
The fair worry with any "lite" path is that you are shipping a worse experience and your numbers will suffer. The author's own data pushes back hard on that fear. On the CSS Wizardry masthead, rich mode ships a full high-resolution image stack and lite mode ships only a low-quality placeholder. Across thousands of real page views measured by SpeedCurve — 3,777 lite versus 4,965 rich — the Largest Contentful Paint differed by roughly eighty milliseconds. Effectively identical perceived speed, for a large bandwidth saving. The lite visitors saved data and battery and barely noticed a difference. That is the entire pitch in one statistic.
Knowing the Limits
Honesty is part of the appeal here, and the author is upfront about the rough edges. The signals Obs.js depends on are largely Chromium-only. The Network Information API, Device Memory, and Hardware Concurrency are mostly absent on Safari, iOS, and Firefox, so on those browsers you get little or no data and must decide deliberately whether the unsupported path is rich or lite. The Network Information API itself is coarse and feels semi-deprecated, which is precisely why Obs.js buckets rather than trusts raw numbers. And the inline-in-head requirement is unusual enough to trip people up the first time. It is the project still being pre-1.0 at version 0.3.0, though it is small, stable, and low-churn by design.
If you want a more React-idiomatic alternative, Addy Osmani's adaptive-loading hooks give you values like useNetworkStatus and useSaveData directly in components. Native CSS media queries like prefers-reduced-motion cover motion, though prefers-reduced-data has almost no support. Server-driven Client Hints can move the decision to the backend. Obs.js stakes out a distinct spot: framework-agnostic, CSS-first, battery-aware, and deployable with a single inline snippet and zero infrastructure.
The Takeaway
Obs.js is a small idea executed with unusual care. It does not promise to make slow connections fast or to revive dead batteries. It promises something more useful and more honest: to tell you what each visitor is actually dealing with, so you can meet them where they are. Whether you wire it into CSS classes, read window.obs from a React hook, or run it purely for analytics, you come away with the one thing most performance work lacks — real context about the human on the other end. For roughly the cost of an inline script tag, that is a remarkable trade.