freeloader
Average download
—
 
Peak download
—
 
Average upload
—
 
Peak upload
—
 
Latency
—
 

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:

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.

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

FieldMeaning
download.bitsPerSecond / .megabitsPerSecondThe estimate. The link is at least this fast.
download.averageBitsPerSecondWeighted mean of what was seen. It sits below bitsPerSecond on purpose.
download.peakBitsPerSecondThe fastest single burst.
download.regressionBitsPerSecondRate from fitting duration against size across transfers.
download.resourcesHow many resources the estimate is built from. Resources downloaded in parallel count individually. upload and latency have the same field.
download.confidencescore (0–1), samples, bytes, spread.
upload.*Same shape as download. It stays null until the page sends a request body.
latency.roundTripMillisecondsLowest round trip seen, in milliseconds.
latency.timeToFirstByteMillisecondsMedian time to first byte.
latency.jitterMillisecondsHow much time to first byte varies, measured only while the link was quiet.
updatedAtEpoch 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.

OptionDefaultWhat it does, and when to change it
storageKey"freeloader" The localStorage key. Change it to keep separate estimates apart.
persisttrue Keep the estimate across page views. Set to false to store nothing.
maximumAgeMilliseconds7 days Stored state older than this is thrown away on load.
halfLifeMilliseconds24 hours A sample's weight halves every this long. Shorten it for users who move between networks a lot.
minimumSampleBytes32768 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.
quantile0.9 Where in the sample distribution the estimate is read. Lower values are more conservative.
maximumSamples100 Throughput samples kept for each direction. The fastest one is kept when old samples are dropped.
maximumLatencySamples100 Latency samples kept.
mergeBurststrue Merge transfers that overlap in time and measure them together. Turning this off makes parallel downloads read slow.
burstGapMilliseconds30 A gap this short between transfers still counts as one burst.
slowStartCorrection1 How much of TCP slow start to subtract, from 0 to 1. Lower it if your traffic mostly uses connections that are already open.
instrumentUploadstrue Wrap fetch and XMLHttpRequest to time request bodies.
originsevery 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.
nowDate.now Clock in epoch milliseconds. For tests.
timeOriginperformance.timeOrigin The epoch millisecond the performance clock starts at. For tests.

API

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

ReasonWhat it meansWhat 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:

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.