Skip to main content
The public Cloudflare TURN server is deprecated and will be discontinued. You must set up your own TURN server to enable packet loss measurement. Follow the steps below.

Background

Packet loss measurement relies on a TURN server to relay UDP packets. Browsers cannot open raw UDP sockets, so all traffic is routed through the TURN relay, which lets the engine count packets that never return. Because TURN credentials must be short-lived to prevent abuse, the recommended pattern is a lightweight server-side worker that:
  1. Holds your TURN API secret securely.
  2. Generates fresh credentials on each request.
  3. Returns them to the browser only from allowed origins.
The example/turn-worker/ directory in the @cloudflare/speedtest repository contains a ready-to-deploy Cloudflare Worker that does exactly this, backed by Cloudflare Realtime TURN.
Cloudflare Realtime TURN servers are subject to billing after the free tier limits are reached. Review the TURN FAQ before deploying to production.

Setup steps

1

Create a Cloudflare Realtime TURN App

  1. In the Cloudflare Dashboard, select Realtime from the sidebar.
  2. Under TURN Server, click Create.
  3. Enter a name for your TURN app and click Create.
  4. Copy the Turn Token ID and API Token — you will need both in the next steps.
2

Clone the example worker

The worker source lives inside the @cloudflare/speedtest repository:
git clone https://github.com/cloudflare/speedtest.git
cd speedtest/example/turn-worker
npm install
3

Configure local development credentials

Create a .dev.vars file at the root of the turn-worker directory with your TURN app credentials:
.dev.vars
REALTIME_TURN_TOKEN_ID=<your-turn-token-id>
REALTIME_TURN_TOKEN_SECRET=<your-api-token>
This file is read by Wrangler during local development with npm run start. It is listed in .gitignore and should never be committed.
4

Add remote secrets with Wrangler

Push the same credentials as encrypted secrets to your deployed worker:
npm exec wrangler secret put REALTIME_TURN_TOKEN_ID
# paste your Turn Token ID when prompted

npm exec wrangler secret put REALTIME_TURN_TOKEN_SECRET
# paste your API Token when prompted
5

Configure routes and allowed origins

Open wrangler.jsonc and update the REALTIME_TURN_ORIGINS variable to list every origin that is allowed to request credentials:
wrangler.jsonc
{
  "name": "turn-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-03-11",
  "vars": {
    // Origins allowed to request TURN credentials, comma-separated.
    "REALTIME_TURN_ORIGINS": "http://localhost:8787,https://speed.example.com,https://staging.speed.example.com",

    // TTL of the generated TURN credentials, in seconds
    "REALTIME_TURN_TOKEN_TTL_SECONDS": 240
  },
  //"workers_dev": false,
  //"routes": [
  //  {
  //    "pattern": "turn.example.com/*",
  //    "zone_id": "<YOUR_ZONE_ID>"
  //  }
  //],
  "observability": {
    "enabled": true
  }
}
To serve the worker from your own domain instead of the default *.workers.dev subdomain, uncomment the routes section and replace turn.example.com and <YOUR_ZONE_ID> with your domain and zone ID.
6

Deploy the worker

npm run deploy
Wrangler prints the worker URL when deployment succeeds. Note the URL — you will use it in the next step.
7

Configure the SpeedTest engine

Pass the worker URL as turnServerCredsApiUrl when instantiating SpeedTest:
import SpeedTest from '@cloudflare/speedtest';

const engine = new SpeedTest({
  turnServerCredsApiUrl: 'https://turn-worker.your-account.workers.dev/turn-credentials',
});

engine.onFinish = results => {
  const loss = results.getPacketLoss();
  console.log(`Packet loss: ${(loss * 100).toFixed(1)}%`);
};
For local development, start the worker with npm run start (listens on http://localhost:8787) and point the engine there:
const engine = new SpeedTest({
  turnServerCredsApiUrl: 'http://localhost:8787/turn-credentials',
});

Worker source code

The complete worker is a single TypeScript file. It validates the request origin, calls the Cloudflare Realtime API to generate short-lived credentials, and returns only the UDP TURN URLs to the browser.
src/index.ts
export default {
  async fetch(request, env, ctx): Promise<Response> {
    // check URL is /turn-credentials
    const url = new URL(request.url);
    if (url.pathname !== '/turn-credentials') {
      return new Response('Not found', {
        status: 404,
      });
    }

    // check if referrer URL is allowed
    const referrer = getRefererURL(request);
    const allowedOrigins = env.REALTIME_TURN_ORIGINS.split(',');
    if (referrer === null || allowedOrigins.indexOf(referrer.origin) === -1) {
      return new Response('Unauthorized', {
        status: 401,
      });
    }

    // request API keys from Cloudflare Realtime
    const res = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${env.REALTIME_TURN_TOKEN_ID}/credentials/generate`, {
      method: 'post',
      headers: {
        authorization: `Bearer ${env.REALTIME_TURN_TOKEN_SECRET}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        ttl: env.REALTIME_TURN_TOKEN_TTL_SECONDS,
      }),
    });

    // check response is acceptable
    if (res.status !== 201) {
      console.log(`Bad response from Cloudflare Realtime API (${res.status} ${res.statusText}): ${await res.text()}`);
      return new Response(`Bad response`, {
        status: 500,
      });
    }

    // parse JSON
    const creds = await res.json<{
      iceServers: {
        urls: string[];
        username: string;
        credential: string;
      };
    }>();

    // return to client
    return new Response(
      JSON.stringify({
        urls: creds.iceServers.urls.filter((urlString) => {
          const url = new URL(urlString);
          return url.protocol === 'turn:' && url.searchParams.get('transport') === 'udp';
        }),
        username: creds.iceServers.username,
        credential: creds.iceServers.credential,
      }),
      {
        headers: {
          'content-type': 'application/json',
          'access-control-allow-origin': referrer.origin,
        },
      },
    );
  },
} satisfies ExportedHandler<Env>;

function getRefererURL(request: Request) {
  const referer = request.headers.get('Referer');
  if (referer === null) {
    return null;
  }

  try {
    return new URL(referer);
  } catch (e) {
    return null;
  }
}

What the worker does

  • Origin check — The worker reads the Referer header and rejects any request whose origin is not in REALTIME_TURN_ORIGINS. This prevents other sites from using your TURN quota.
  • Credential generation — It calls the Cloudflare Realtime REST API (/v1/turn/keys/{id}/credentials/generate) with a short TTL (default 240 seconds) so credentials cannot be reused beyond a single test session.
  • UDP-only filter — The response filters the returned ICE server URLs to keep only turn: addresses with transport=udp, which is what the packet loss engine requires.
  • CORS header — The access-control-allow-origin response header is set to the exact requesting origin so browsers honour the response.

Credential response format

The SpeedTest engine expects turnServerCredsApiUrl to return a JSON object with the following shape:
{
  "username": "short-lived-username",
  "credential": "short-lived-password",
  "urls": ["turn:turn.cloudflare.com:3478?transport=udp"]
}
The urls field is optional. If omitted, the engine uses the turnServerUri constructor option for the server address.

Further reading

Build docs developers (and LLMs) love