Skip to main content

Overview

The SpeedTest engine communicates state changes through five assignable callback properties. Assign a function to any of these properties on your engine instance to receive notifications.
const engine = new SpeedTest({ autoStart: false });

engine.onRunningChange = (running) => { /* ... */ };
engine.onResultsChange = ({ type }) => { /* ... */ };
engine.onPhaseChange = ({ measurement, measurementId }) => { /* ... */ };
engine.onFinish = (results) => { /* ... */ };
engine.onError = (error) => { /* ... */ };

engine.play();
Callbacks can be assigned before or after calling play(). If you assign onFinish after measurements have already completed, it will not be called retroactively — assign callbacks before starting for reliable delivery.
onResultsChange fires many times throughout a test run (once per individual data point). Use it for live UI updates. Use onFinish for final result display — it fires exactly once when all measurements complete.

onRunningChange

onRunningChange: (running: boolean) => void
Fires whenever the engine transitions between running and not-running states. When it fires:
  • When play() successfully starts or resumes the engine → running: true
  • When pause() is called → running: false
  • When the last measurement in the sequence completes → running: false
  • When restart() clears the engine (briefly false before the new run starts true)
Arguments:
running
boolean
required
The new value of engine.isRunning after the transition.
Example: show a loading spinner
const spinner = document.getElementById('spinner')!;
const statusText = document.getElementById('status')!;

engine.onRunningChange = (running: boolean) => {
  spinner.style.display = running ? 'block' : 'none';
  statusText.textContent = running ? 'Test in progress…' : 'Test paused';
};

onResultsChange

onResultsChange: ({ type: string }) => void
Fires whenever any measurement result is updated. This is the primary event for building live dashboards — it fires on every individual data point as it arrives. When it fires:
  • At the start of each measurement step (initial state update)
  • After every individual latency probe completes
  • After every individual download or upload request completes
  • After every packet received during a packet loss measurement
  • When a measurement step finishes
Arguments:
type
string
required
The category of measurement that changed. One of:
  • "latency" — unloaded latency probe completed
  • "download" — download bandwidth measurement updated
  • "upload" — upload bandwidth measurement updated
  • "packetLoss" — packet loss measurement updated
Example: live dashboard
engine.onResultsChange = ({ type }: { type: string }) => {
  const results = engine.results;

  switch (type) {
    case 'latency': {
      const latency = results.getUnloadedLatency();
      const jitter = results.getUnloadedJitter();
      if (latency !== undefined) {
        document.getElementById('latency')!.textContent = `${latency.toFixed(1)} ms`;
      }
      if (jitter !== undefined) {
        document.getElementById('jitter')!.textContent = `${jitter.toFixed(1)} ms`;
      }
      break;
    }
    case 'download': {
      const bw = results.getDownloadBandwidth();
      if (bw !== undefined) {
        document.getElementById('download')!.textContent =
          `${(bw / 1e6).toFixed(1)} Mbps`;
      }
      break;
    }
    case 'upload': {
      const bw = results.getUploadBandwidth();
      if (bw !== undefined) {
        document.getElementById('upload')!.textContent =
          `${(bw / 1e6).toFixed(1)} Mbps`;
      }
      break;
    }
    case 'packetLoss': {
      const loss = results.getPacketLoss();
      if (loss !== undefined) {
        document.getElementById('packetLoss')!.textContent =
          `${(loss * 100).toFixed(2)}%`;
      }
      break;
    }
  }
};
results.getDownloadBandwidth() (and similar methods) return undefined until enough data has been collected. Always guard with an !== undefined check before rendering values.

onPhaseChange

onPhaseChange: ({ measurement: MeasurementConfig, measurementId: number }) => void
Fires each time the engine advances to the next measurement in the sequence. When it fires:
  • Once per entry in the measurements array, in order, as the engine starts each step
Arguments:
measurement
MeasurementConfig
required
The configuration object for the measurement that is about to begin. This is the same object that was provided in the measurements array (e.g. { type: 'download', bytes: 1000000, count: 8 }).
measurementId
number
required
The zero-based index of this measurement within the measurements array. Useful for computing a progress percentage.
Example: progress indicator
import SpeedTest from '@cloudflare/speedtest';
import type { MeasurementConfig } from '@cloudflare/speedtest';

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

const progressBar = document.getElementById('progress')! as HTMLProgressElement;
const phaseLabel = document.getElementById('phase')!;

// Default config has 15 measurement steps
const TOTAL_STEPS = 15;

engine.onPhaseChange = ({
  measurement,
  measurementId,
}: {
  measurement: MeasurementConfig;
  measurementId: number;
}) => {
  progressBar.value = measurementId;
  progressBar.max = TOTAL_STEPS;
  phaseLabel.textContent = `Step ${measurementId + 1} of ${TOTAL_STEPS}: ${measurement.type}`;
};

engine.play();

onFinish

onFinish: (results: Results) => void
Fires exactly once when all measurements in the sequence complete. At this point every result method on the Results object returns its final value. When it fires:
  • After the last measurement in the measurements array finishes
  • The engine sets isRunning to false and isFinished to true before this callback is invoked
Arguments:
results
Results
required
The final Results object. All methods return their fully computed values. This is the same object accessible via engine.results.
Example: final summary and scores
engine.onFinish = (results) => {
  const summary = results.getSummary();
  const scores = results.getScores();

  console.log('Summary:', {
    download: summary.download
      ? `${(summary.download / 1e6).toFixed(1)} Mbps`
      : 'N/A',
    upload: summary.upload
      ? `${(summary.upload / 1e6).toFixed(1)} Mbps`
      : 'N/A',
    latency: summary.latency ? `${summary.latency.toFixed(1)} ms` : 'N/A',
    jitter: summary.jitter ? `${summary.jitter.toFixed(1)} ms` : 'N/A',
    packetLoss: summary.packetLoss !== undefined
      ? `${(summary.packetLoss * 100).toFixed(2)}%`
      : 'N/A',
  });

  // AIM scores classify connection quality per use case
  for (const [useCase, score] of Object.entries(scores)) {
    console.log(`${useCase}: ${score.classificationName} (${score.points} pts)`);
  }

  document.getElementById('results-panel')!.style.display = 'block';
};
onFinish is invoked asynchronously via setTimeout with a delay of 0 after the last measurement completes. This ensures the isFinished getter is already true by the time your callback runs.

onError

onError: (error: string) => void
Fires when a measurement fails due to a network or configuration error. The engine logs the error and continues to the next measurement rather than stopping the entire test. When it fires:
  • Connection error during a latency measurement
  • Connection error during a download measurement
  • Connection error during an upload measurement
  • Connection error when connecting to the TURN server for packet loss
  • Failure to obtain TURN server credentials
Arguments:
error
string
required
A human-readable description of the error, including the measurement type and the underlying cause. Example:
"Connection error while measuring download: Failed to fetch"
"Error while measuring packet loss: unable to get turn server credentials."
Example: error toast
engine.onError = (error: string) => {
  console.error('SpeedTest error:', error);

  const toast = document.createElement('div');
  toast.className = 'toast toast-error';
  toast.textContent = `Measurement error: ${error}`;
  document.body.appendChild(toast);

  setTimeout(() => toast.remove(), 5000);
};

All events wired together

The following example shows all five callbacks configured on a single engine instance.
import SpeedTest from '@cloudflare/speedtest';
import type { MeasurementConfig, Results } from '@cloudflare/speedtest';

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

engine.onRunningChange = (running: boolean) => {
  console.log('[running]', running);
};

engine.onPhaseChange = ({
  measurement,
  measurementId,
}: {
  measurement: MeasurementConfig;
  measurementId: number;
}) => {
  console.log(`[phase] ${measurementId}: ${measurement.type}`);
};

engine.onResultsChange = ({ type }: { type: string }) => {
  // Fires many times — keep this handler fast
  const r = engine.results;
  if (type === 'download' && r.getDownloadBandwidth() !== undefined) {
    console.log(`[download] ${((r.getDownloadBandwidth()!) / 1e6).toFixed(2)} Mbps`);
  }
};

engine.onError = (error: string) => {
  console.warn('[error]', error);
  // Engine continues to next measurement automatically
};

engine.onFinish = (results: Results) => {
  // Fires once — safe to do more expensive work here
  console.log('[finish]', results.getSummary());
  console.log('[scores]', results.getScores());
};

engine.play();

onResultsChange vs onFinish

onResultsChangeonFinish
FiresMany times during the testOnce, at completion
TimingAfter each individual data pointAfter all measurements finish
Results statePartial — values may be undefinedFinal — all values available
Use forLive progress UI, streaming updatesFinal summary, AIM scores, reporting
PerformanceKeep handler fast; it runs frequentlyCan do heavier work here

Build docs developers (and LLMs) love