Skip to main content
This guide shows how to integrate @cloudflare/speedtest into a React application. The pattern uses a custom hook to manage the engine lifecycle and exposes results as React state.
The SDK relies on the PerformanceResourceTiming browser API, which is not available in server-side rendering (SSR) environments such as Next.js. Either import the engine inside a useEffect, or use dynamic import with { ssr: false }.

The useSpeedTest hook

import { useState, useEffect, useRef } from 'react';
import SpeedTest from '@cloudflare/speedtest';

function useSpeedTest() {
  const [results, setResults]     = useState(null);
  const [isRunning, setIsRunning] = useState(false);
  const [isFinished, setIsFinished] = useState(false);
  const engineRef = useRef(null);

  function start() {
    const engine = new SpeedTest({ autoStart: false });

    engine.onRunningChange = running => setIsRunning(running);

    engine.onResultsChange = () => {
      setResults(engine.results.getSummary());
    };

    engine.onFinish = finalResults => {
      setResults(finalResults.getSummary());
      setIsFinished(true);
    };

    engineRef.current = engine;
    engine.play();
  }

  function pause() {
    engineRef.current?.pause();
  }

  function restart() {
    setIsFinished(false);
    setResults(null);
    engineRef.current?.restart();
  }

  return { results, isRunning, isFinished, start, pause, restart };
}
The hook manages three pieces of state:
StateDescription
resultsLatest getSummary() snapshot; updated after each measurement.
isRunningMirrors engine.isRunning; true while transfers are active.
isFinishedtrue once all configured measurements have completed.

The SpeedTestWidget component

function SpeedTestWidget() {
  const { results, isRunning, isFinished, start, pause, restart } = useSpeedTest();

  const fmt = (bps) =>
    bps != null ? `${(bps / 1e6).toFixed(1)} Mbps` : '—';

  const fmtMs = (ms) =>
    ms != null ? `${ms.toFixed(1)} ms` : '—';

  return (
    <div>
      {/* Controls */}
      <div>
        {!isRunning && !isFinished && (
          <button onClick={start}>Start test</button>
        )}
        {isRunning && (
          <button onClick={pause}>Pause</button>
        )}
        {!isRunning && isFinished && (
          <button onClick={restart}>Run again</button>
        )}
        {!isRunning && !isFinished && results && (
          <button onClick={restart}>Restart</button>
        )}
      </div>

      {/* Live metrics */}
      {results && (
        <table>
          <tbody>
            <tr>
              <th>Download</th>
              <td>{fmt(results.download)}</td>
            </tr>
            <tr>
              <th>Upload</th>
              <td>{fmt(results.upload)}</td>
            </tr>
            <tr>
              <th>Latency</th>
              <td>{fmtMs(results.latency)}</td>
            </tr>
            <tr>
              <th>Jitter</th>
              <td>{fmtMs(results.jitter)}</td>
            </tr>
          </tbody>
        </table>
      )}

      {/* AIM scores — only available after the test finishes */}
      {isFinished && results && <AimScores engine={engineRef} />}
    </div>
  );
}
results holds the output of getSummary(), which returns bandwidth in bps. Divide by 1e6 before displaying to users, as shown in the fmt helper above.

Displaying AIM scores

getScores() is only available after the engine has finished all measurements. It returns an object keyed by use-case category, each with a numeric score and a named classification:
function AimScores({ engine }) {
  const scores = engine.current?.results.getScores();

  if (!scores) return null;

  return (
    <div>
      <h3>Performance scores</h3>
      {Object.entries(scores).map(([category, score]) => (
        <div key={category}>
          <strong>{category}</strong>:{' '}
          {score.points} points —{' '}
          <span data-class={score.classificationName}>
            {score.classificationName}
          </span>
        </div>
      ))}
    </div>
  );
}
Each entry in the scores object has this shape:
{
  points: number;
  classificationIdx: 0 | 1 | 2 | 3 | 4;
  classificationName: 'bad' | 'poor' | 'average' | 'good' | 'great';
}

SSR-safe usage (Next.js)

Because the SDK requires browser APIs, it must not run during server-side rendering. Two approaches work:
import { useState, useEffect, useRef } from 'react';

function useSpeedTest() {
  const [SpeedTest, setSpeedTest] = useState(null);
  const engineRef = useRef(null);

  useEffect(() => {
    // Only import on the client
    import('@cloudflare/speedtest').then(mod => {
      setSpeedTest(() => mod.default);
    });
  }, []);

  function start() {
    if (!SpeedTest) return;
    const engine = new SpeedTest({ autoStart: false });
    engineRef.current = engine;
    engine.play();
  }

  return { start };
}

Complete example

Putting it all together — install the package, then use the hook and component above:
1

Install the package

npm install @cloudflare/speedtest
2

Create the hook file

Copy the useSpeedTest hook into src/hooks/useSpeedTest.js (or .ts).
3

Build the component

Use SpeedTestWidget as a client component. In the Next.js App Router, add 'use client' at the top of the file.
'use client';

import { useSpeedTest } from '../hooks/useSpeedTest';

export function SpeedTestWidget() {
  // ... (component body from above)
}
4

Handle cleanup on unmount

If the component can unmount while a test is running, pause and discard the engine reference:
useEffect(() => {
  return () => {
    engineRef.current?.pause();
    engineRef.current = null;
  };
}, []);

Build docs developers (and LLMs) love