Using Freeloader
Start it once, last, and read the estimate whenever you need it. It makes no requests of its own, so there is nothing to schedule and nothing to budget for.
Install
Freeloader is one file with no dependencies. Download it, copy it into your project and import it:
-
TypeScript:
freeloader.ts, the source itself. -
JavaScript:
freeloader.js, plusfreeloader.d.tsif you want types in your editor.
To build all three from the repository:
npm run build # writes dist/freeloader.ts, freeloader.js and freeloader.d.ts
Where to start it
Once per page load, in the browser. Load it last.
Freeloader reads download timings from the browser's Resource Timing records, which the
browser keeps for everything the page has already loaded. So it doesn't need to run first:
downloads that finished before start() still count, and loading it last keeps
it out of your page's way.
-
Uploads are the one exception: they're timed by wrapping
fetchandXMLHttpRequest, so uploads sent beforestart()aren't measured. - Never on the server. In Node it would wrap the server's
fetch.
Script tag
<!DOCTYPE html>
<html>
<head>
<script type="module" src="/app.js"></script>
</head>
<body>
<!-- content -->
<script type="module">
// Start after everything else has loaded. Downloads so far are still read
// from the browser's timing records; uploads before this point are not timed.
addEventListener("load", async () => {
const { Freeloader } = await import("/freeloader.js");
window.freeloader = Freeloader.start();
}, { once: true });
</script>
</body>
</html>
Until the page's load event, window.freeloader is
undefined, so check for it before reading it. Freeloader is an ES module, so it
needs type="module" or import(); a classic
<script> won't work.
Bundled apps
Copy freeloader.js into your source folder, create one shared instance, and
import it from your entry file:
// src/network.ts
import { Freeloader } from "./freeloader.js";
export const network = Freeloader.start();
React
// src/main.tsx
import "./network";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);
// src/useNetwork.ts
import { useSyncExternalStore } from "react";
import { network } from "./network";
export function useNetwork() {
return useSyncExternalStore(
(onChange) => network.subscribe(onChange),
() => network.getEstimate(),
);
}
// src/NetworkStatus.tsx
import { formatBitsPerSecond, formatMilliseconds } from "./freeloader.js";
import { useNetwork } from "./useNetwork";
export function NetworkStatus() {
const { download, upload, latency } = useNetwork();
return (
<p>
↓ {formatBitsPerSecond(download.bitsPerSecond)} · ↑ {formatBitsPerSecond(upload.bitsPerSecond)} ·{" "}
{formatMilliseconds(latency.roundTripMilliseconds)} ping
</p>
);
}
Next.js
instrumentation-client.ts (Next.js 15.3+) runs in the browser before
hydration.
// lib/network.ts
import { Freeloader } from "./freeloader.js";
export const network = typeof window === "undefined" ? null : Freeloader.start();
// instrumentation-client.ts
import "./lib/network";
// lib/useNetwork.ts
"use client";
import { useSyncExternalStore } from "react";
import { network } from "./network";
const subscribe = (onChange: () => void) => network?.subscribe(onChange) ?? (() => {});
export function useNetwork() {
return useSyncExternalStore(subscribe, () => network?.getEstimate() ?? null, () => null);
}
useNetwork() returns null during server rendering and hydration.
On the Pages Router, import ../lib/network at the top of
pages/_app.tsx instead.
Vue
// src/main.ts
import "./network";
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
// src/useNetwork.ts
import { onScopeDispose, shallowRef } from "vue";
import { network } from "./network";
export function useNetwork() {
const estimate = shallowRef(network.getEstimate());
onScopeDispose(network.subscribe((next) => (estimate.value = next)));
return estimate;
}
On Nuxt, use a client-only plugin instead:
// plugins/freeloader.client.ts
import { Freeloader } from "~/freeloader.js";
export default defineNuxtPlugin(() => ({ provide: { network: Freeloader.start() } }));
const { $network } = useNuxtApp();
Svelte and SvelteKit
// src/lib/network.ts
import { browser } from "$app/environment";
import { readable } from "svelte/store";
import { Freeloader } from "./freeloader.js";
export const network = browser ? Freeloader.start() : null;
export const estimate = readable(network?.getEstimate() ?? null, (set) => network?.subscribe(set));
// src/hooks.client.ts
import "$lib/network";
<p>{$estimate?.download.megabitsPerSecond ?? "—"} Mbps</p>
Without SvelteKit, drop the browser check and import
./lib/network in main.ts.
Angular
// src/main.ts
import "./network";
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { appConfig } from "./app/app.config";
bootstrapApplication(AppComponent, appConfig);
// src/app/network.service.ts
import { Injectable, signal } from "@angular/core";
import { network } from "../network";
@Injectable({ providedIn: "root" })
export class NetworkService {
readonly estimate = signal(network.getEstimate());
constructor() {
network.subscribe((next) => this.estimate.set(next));
}
}
With server-side rendering, use the typeof window check from the Next.js example.
Reading the estimate
const freeloader = Freeloader.start();
const { download, upload, latency } = freeloader.getEstimate();
const unsubscribe = freeloader.subscribe((estimate) => {
console.log(estimate.download.megabitsPerSecond, estimate.latency.roundTripMilliseconds);
});
getEstimate() is cheap, because the estimate is recalculated when new data
arrives, not when you read it. Every figure can be null until the page has
seen enough traffic, so always handle that case.
What the fields mean
| Field | Meaning |
|---|---|
download.bitsPerSecond / .megabitsPerSecond | The estimate. The link is at least this fast. |
download.averageBitsPerSecond | Weighted mean of what was seen. It sits below bitsPerSecond on purpose. |
download.peakBitsPerSecond | The fastest single burst. |
download.regressionBitsPerSecond | Rate from fitting duration against size across transfers. |
download.resources | How many resources the estimate is built from. Resources downloaded in parallel count individually. upload and latency have the same field. |
download.confidence | score (0–1), samples, bytes, spread. |
upload.* | Same shape as download. It stays null until the page sends a request body. |
latency.roundTripMilliseconds | Lowest round trip seen, in milliseconds. |
latency.timeToFirstByteMilliseconds | Median time to first byte. |
latency.jitterMilliseconds | How much time to first byte varies, measured only while the link was quiet. |
updatedAt | Epoch milliseconds of the newest sample. |
Treat a figure with low confidence as a floor ("at least this fast"), not as a measurement. Passive samples only ever make a link look slower than it is.
Common tasks
Pick a video or image quality
function pickRendition() {
const { download } = freeloader.getEstimate();
if (download.bitsPerSecond === null || download.confidence.score < 0.3) return "720p";
if (download.megabitsPerSecond >= 25) return "2160p";
if (download.megabitsPerSecond >= 8) return "1080p";
return "480p";
}
Show the numbers in your UI
The package exports the formatters this demo uses:
import { formatBitsPerSecond, formatMilliseconds, formatBytes } from "./freeloader.js";
formatBitsPerSecond(42_300_000); // "42.3 Mbps"
formatMilliseconds(18.4); // "18 ms"
formatBytes(3_400_000); // "3.4 MB"
formatBitsPerSecond(null); // "—"
Measure only your own origins
Freeloader.start({
origins: [location.origin, "https://cdn.example.com"],
});
Transfers from other origins are counted as foreign-origin in
debug().skipped and ignored. Assets on a different origin are only
measurable if that origin sends a Timing-Allow-Origin header. Without it the
browser hides the timings, so add it to your CDN:
Timing-Allow-Origin: https://www.example.com
Leave fetch and XMLHttpRequest alone
Freeloader.start({ instrumentUploads: false });
By default the library wraps fetch and XMLHttpRequest to time
request bodies. It never reads or copies the body, only its size, and bodies under 4 KB
are ignored. Turn this off if you already wrap those APIs, and there will be no upload
figure.
Keep nothing between page views
Freeloader.start({ persist: false });
The estimate starts from zero on every load and nothing is written to
localStorage. By default one key, freeloader, holds the
estimate for up to seven days.
Run several independent estimators
const pageEstimate = Freeloader.start({ storageKey: "myapp:network" });
Give each one its own storageKey so they don't overwrite each other.
Let the user clear it
resetButton.onclick = () => freeloader.reset();
This also deletes the stored copy.
Options
Pass these to Freeloader.start(options) or
new Freeloader(options). Every option is optional.
| Option | Default | What it does, and when to change it |
|---|---|---|
storageKey | "freeloader" |
The localStorage key. Change it to keep separate estimates apart. |
persist | true |
Keep the estimate across page views. Set to false to store nothing. |
maximumAgeMilliseconds | 7 days | Stored state older than this is thrown away on load. |
halfLifeMilliseconds | 24 hours | A sample's weight halves every this long. Shorten it for users who move between networks a lot. |
minimumSampleBytes | 32768 | Transfers smaller than this are ignored for download and upload speed, but still count for latency. Lower it only if your pages are made entirely of small files. |
quantile | 0.9 |
Where in the sample distribution the estimate is read. Lower values are more conservative. |
maximumSamples | 100 | Throughput samples kept for each direction. The fastest one is kept when old samples are dropped. |
maximumLatencySamples | 100 | Latency samples kept. |
mergeBursts | true |
Merge transfers that overlap in time and measure them together. Turning this off makes parallel downloads read slow. |
burstGapMilliseconds | 30 | A gap this short between transfers still counts as one burst. |
slowStartCorrection | 1 |
How much of TCP slow start to subtract, from 0 to 1. Lower it if your traffic mostly uses connections that are already open. |
instrumentUploads | true |
Wrap fetch and XMLHttpRequest to time request bodies. |
origins | every origin | Only observe transfers from these origins, for example ["https://cdn.example.com"]. |
onUpdate | — | Called with the new estimate on every change. Works like subscribe(), set at construction time. |
now | Date.now |
Clock in epoch milliseconds. For tests. |
timeOrigin | performance.timeOrigin |
The epoch millisecond the performance clock starts at. For tests. |
API
| Call | What it does |
|---|---|
Freeloader.start(options?) | Create an instance and start observing. |
new Freeloader(options?, storage?) | Create an instance without starting it. storage replaces localStorage; pass null to use no storage. |
.start() | Start observing. A second call does nothing. |
.stop() | Stop observing, un-patch fetch and XMLHttpRequest, and save state. |
.isRunning | Whether it is observing. |
.getEstimate() | The current estimate. |
.subscribe(fn) | Call fn on every change. Returns an unsubscribe function. |
.reset() | Forget everything, including the stored copy. |
.debug() | Sample counts, totals, recent samples, and why entries were skipped. |
.ingest(entry) | Add a performance entry by hand. |
.flush(nowPerf?) | Process buffered entries now instead of waiting. |
Debugging a low or missing figure
Open the console and run freeloader.debug() on any page of this demo (the
instance is on window.freeloader), or open the
Network page. skipped counts every entry that was
ignored, by reason:
| Reason | What it means | What to do |
|---|---|---|
cache-hit |
Served from the browser cache, so nothing crossed the network. | Nothing. Repeat visits are expected to be quiet. |
no-transfer-size |
The browser reported no size for the transfer. | Usually a cross-origin asset. Send Timing-Allow-Origin from that origin. |
opaque-timings |
The browser hid the timestamps. | Same fix: send Timing-Allow-Origin. |
window-too-short |
The transfer finished too fast to time. It still gives a latency sample. | Nothing. Tiny files can't reveal bandwidth. |
foreign-origin |
The origin isn't in your origins list. |
Add it to the list if you want it counted. |
If confidence stays low, the page probably moved too few large transfers. Things to check:
- The page moves at least a few files over 32 KB. Light pages like About stay low on purpose.
- Upload is
null: nothing has sent a request body of 4 KB or more yet.
Testing code that uses it
Pass fixed clocks and null storage, add entries by hand, and flush on your
own clock. Nothing depends on a real network.
import { Freeloader } from "./freeloader.js";
let clock = 0;
const freeloader = new Freeloader(
{ now: () => 1_700_000_000_000 + clock, timeOrigin: 1_700_000_000_000, instrumentUploads: false },
null,
);
freeloader.ingest({
name: "https://example.com/hero.jpg",
entryType: "resource",
startTime: 0, duration: 240, requestStart: 10,
responseStart: 40, responseEnd: 240,
transferSize: 500_000, encodedBodySize: 499_700,
});
clock = 1000;
freeloader.flush(clock);
console.log(freeloader.getEstimate().download.megabitsPerSecond);
Browser support
All modern browsers are supported: Chrome, Edge, Firefox and Safari, on desktop and mobile.
Outside a browser, for example during server-side rendering, starting it is safe. It observes nothing and the estimate stays empty.