../

WebRTC

Peer-to-peer audio, video and data in the browser with RTCPeerConnection: signaling, ICE/STUN/TURN, negotiation, data channels, stats, topologies and the servers you still need. Capture lives in Media devices, raw encoding in WebCodecs.

Architecture

WebRTC standardizes the media path, not the discovery path. You bring a signaling channel (WebSocket, HTTP, anything) to swap SDP and ICE candidates; after that, media flows peer to peer or through a TURN relay.

   Peer A                                         Peer B
 ┌───────────────────┐   SDP offer/answer    ┌───────────────────┐
 │ RTCPeerConnection │ ◄── ICE candidates ──►│ RTCPeerConnection │
 └─────────┬─────────┘   (your signaling     └─────────┬─────────┘
           │              server, e.g. WS)             │
           │  "what is my public ip:port?"             │
           ├──────────────► STUN ◄─────────────────────┤
           │                                           │
           │ ◄════ direct UDP: DTLS-SRTP + SCTP ═════► │
           │                                           │
           └────────────► TURN relay ◄─────────────────┘
                     (fallback when NAT/firewall
                      blocks the direct path)
   audio / video            RTCDataChannel
 ┌──────────────┐          ┌──────────────┐
 │     SRTP     │          │     SCTP     │
 └──────┬───────┘          ├──────────────┤
        │ keys from DTLS   │     DTLS     │
 ┌──────┴──────────────────┴──────────────┐
 │        ICE (STUN checks, TURN)         │
 ├────────────────────────────────────────┤
 │   UDP  (TCP or TLS/443 via TURN)       │
 └────────────────────────────────────────┘
PieceSpecJob
SDP / JSEPRFC 8866, RFC 8829text blob describing codecs, tracks, ICE credentials, DTLS fingerprint
Signalingnot specifiedyour channel that carries SDP and candidates between peers
ICERFC 8445gathers candidate addresses, runs connectivity checks, picks a pair
STUNRFC 8489tells a peer its public address (server-reflexive); ICE check packets
TURNRFC 8656relays traffic when no direct path works; costs bandwidth
DTLSRFC 9147handshake over UDP; keys SRTP and encrypts SCTP
SRTPRFC 3711encrypted RTP media (DTLS-SRTP, RFC 5764)
SCTPRFC 8831message transport under data channels: ordered or not, reliable or not

Support & TypeScript

FeatureStatus (MDN / Baseline)
RTCPeerConnection, getStats(), RTCDataChannelBaseline widely available (since 2020)
Implicit setLocalDescription(), restartIce()widely available
connectionState, pc.sctp (RTCSctpTransport)widely available (Firefox 113+)
setCodecPreferences()Baseline 2024 (Firefox 128)
RTCRtpScriptTransform (encoded transforms, E2EE)Baseline 2025 (Chrome 141)
"transport" stats entryFirefox added it in 153: not Baseline yet
Mandatory codecsVP8, H.264 Constrained Baseline, Opus, G.711; VP9, AV1, H.265 optional

All need a secure context (HTTPS or localhost) for camera and mic; a data-only connection works anywhere. Bun and Node have no built-in RTCPeerConnection; use a library (see Libraries) for server-side peers.

  • lib.dom ships every core type: RTCConfiguration, RTCIceServer, RTCSessionDescriptionInit, RTCIceCandidateInit, RTCDataChannelInit, RTCRtpEncodingParameters, RTCStatsType.
  • RTCStatsReport is a Map<string, any>: narrow on s.type and treat fields as optional.
  • pc.localDescription is nullable; it is set once setLocalDescription() resolves.
  • RTCSessionDescription and RTCIceCandidate have toJSON(), so send them through JSON.stringify and type the wire format as the *Init dictionaries.
  • TS 5.9 lib.dom lacks scalabilityMode on RTCRtpEncodingParameters; extend the type for SVC.

Connection lifecycle

StepOfferer (caller)Answerer (callee)
1new RTCPeerConnection(config)new RTCPeerConnection(config)
2addTrack() / createDataChannel() fires negotiationneeded
3setLocalDescription() makes the offer; send localDescription
4setRemoteDescription(offer)
5setLocalDescription() makes the answer; send it back
6setRemoteDescription(answer)
7trickle icecandidate events to the other sidesame, both ways
8ICE checks, DTLS handshake: connectionState is "connected"same
9pc.close() and stop local trackssame

setLocalDescription() with no argument creates the right offer or answer for the current signalingState; you rarely need createOffer()/createAnswer() any more.

const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
 
pc.addEventListener("negotiationneeded", async () => {
  await pc.setLocalDescription(); // implicit offer
  signal({ description: pc.localDescription!.toJSON() });
});
 
declare function signal(msg: unknown): void;
signalingStateMeaning
stableno exchange in progress
have-local-offerwe sent an offer and wait for the answer
have-remote-offerwe received an offer and owe an answer
have-local-pranswer / have-remote-pranswerprovisional answer (rare)
closedclose() was called

Perfect negotiation

Renegotiation happens whenever tracks or channels change, so both peers may send an offer at the same time ("glare"). The perfect negotiation pattern gives the two peers asymmetric roles and one code path; the recipe has the full handler.

PieceRule
polite peeron collision, drops its own offer (implicit rollback) and answers the remote one
impolite peeron collision, ignores the incoming offer; its own offer wins
makingOffertrue while our setLocalDescription() for an offer is in flight
collisionan offer arrives while makingOffer or signalingState !== "stable"
ignoreOfferset by the impolite peer; also swallow addIceCandidate errors for that offer
rolesdecide out of band, e.g. whoever joined the room second is polite

ICE, STUN & TURN

Candidate typeAddressWhere it comes from
hostlocal interface (often an mDNS *.local name)the machine itself
srflxpublic ip:port seen by a STUN serverserver-reflexive, through the NAT
prflxaddress learned during connectivity checkspeer-reflexive
relayaddress on a TURN serverTURN allocation
config.ts
export const config: RTCConfiguration = {
  iceServers: [
    { urls: "stun:stun.example.com:3478" },
    {
      urls: [
        "turn:turn.example.com:3478?transport=udp",
        "turns:turn.example.com:443?transport=tcp",
      ],
      username: "1790000000:alice", // short-lived
      credential: "base64-hmac",
    },
  ],
  iceTransportPolicy: "all", // "relay": TURN only
  bundlePolicy: "max-bundle", // one transport for all
  iceCandidatePoolSize: 0, // >0 pre-gathers
};
RTCConfiguration keyValuesNotes
iceServersRTCIceServer[]urls accepts stun:, turn:, turns:
iceTransportPolicy"all" | "relay""relay" hides client IPs, forces TURN
bundlePolicy"balanced", "max-compat", "max-bundle"max-bundle = one port for everything
iceCandidatePoolSize0–255gather before setLocalDescription()
certificatesRTCCertificate[]from RTCPeerConnection.generateCertificate()

Trickle ICE: send each icecandidate as it fires instead of waiting for gathering to finish; event.candidate === null means gathering is complete. The signaling channel must keep order so the description arrives before its candidates.

pc.addEventListener("icecandidate", ({ candidate }) => {
  if (candidate) signal({ candidate: candidate.toJSON() });
});
 
// ICE restart after a network change or failure
pc.addEventListener("iceconnectionstatechange", () => {
  if (pc.iceConnectionState === "failed") pc.restartIce();
});

restartIce() fires negotiationneeded; the next offer carries fresh ICE credentials.

Tracks & transceivers

Every m-line in the SDP is an RTCRtpTransceiver: a sender plus a receiver sharing a mid.

const stream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true,
});
for (const track of stream.getTracks()) {
  pc.addTrack(track, stream); // stream ids tie a/v together
}
 
pc.addEventListener("track", ({ track, streams }) => {
  remote.srcObject = streams[0] ?? new MediaStream([track]);
});
 
declare const remote: HTMLVideoElement;
CallRenegotiates?Use
pc.addTrack(track, ...streams)yessend a track; reuses a free transceiver
pc.removeTrack(sender)yesstop sending; transceiver stays
pc.addTransceiver(trackOrKind, init)yesexplicit direction, sendEncodings, receive-only
sender.replaceTrack(track)noswap camera, start screen share, mute with null
sender.setParameters(params)nomaxBitrate, maxFramerate, active, scaleResolutionDownBy
transceiver.direction = "recvonly"yessendrecv, sendonly, recvonly, inactive
transceiver.setCodecPreferences(codecs)next offerorder codecs; call before negotiating
transceiver.stop()yespermanently kill the m-line
RTCRtpSender.getCapabilities("video")nolist supported codecs
// cap the video bitrate without renegotiating
async function capBitrate(
  sender: RTCRtpSender,
  bps: number,
) {
  const params = sender.getParameters();
  await sender.setParameters({
    ...params,
    encodings: params.encodings.map((e) => ({
      ...e,
      maxBitrate: bps,
    })),
  });
}
 
// prefer AV1, keep the rest as fallbacks
function preferCodec(t: RTCRtpTransceiver, mime: string) {
  const caps = RTCRtpReceiver.getCapabilities("video");
  const all = caps?.codecs;
  if (!all) return;
  t.setCodecPreferences([
    ...all.filter((c) => c.mimeType === mime),
    ...all.filter((c) => c.mimeType !== mime),
  ]);
}
preferCodec(pc.getTransceivers()[0]!, "video/AV1");

Data channels

// reliable + ordered by default
const chat = pc.createDataChannel("chat");
const state = pc.createDataChannel("state", {
  ordered: false,
  maxRetransmits: 0, // fire and forget
});
 
// remote side, for channels it didn't create itself
pc.addEventListener("datachannel", ({ channel }) => {
  channel.addEventListener("message", (e) => {
    console.log(channel.label, e.data);
  });
});
RTCDataChannelInitDefaultEffect
orderedtruedeliver in send order (head-of-line blocking)
maxRetransmitsunlimitedgive up after N retransmissions
maxPacketLifeTimeunlimitedgive up after N ms; exclusive with maxRetransmits (TypeError)
negotiated + idfalseboth sides call createDataChannel with the same id; no datachannel event
protocol""sub-protocol label for the app
ModeOptionsGood for
reliable, ordered{}chat, RPC, file transfer
reliable, unordered{ ordered: false }independent messages
unreliable{ ordered: false, maxRetransmits: 0 }cursors, game state, telemetry
timed{ ordered: false, maxPacketLifeTime: 150 }stale-after-150 ms updates
MemberNotes
readyStateconnecting, open, closing, closed; send() throws unless open
send(data)string, Blob, ArrayBuffer, ArrayBufferView
binaryType"arraybuffer" (default) or "blob"
bufferedAmountbytes queued; watch it for backpressure
bufferedAmountLowThresholdfires bufferedamountlow when the queue drops to it
pc.sctp?.maxMessageSizepeer's limit from SDP (64 KiB if absent); chunk files to 16–64 KiB
// send a big file without blowing the SCTP buffer
async function sendFile(dc: RTCDataChannel, file: Blob) {
  const CHUNK = 16 * 1024;
  const HIGH = 4 * 1024 * 1024;
  dc.bufferedAmountLowThreshold = 1024 * 1024;
  for (let at = 0; at < file.size; at += CHUNK) {
    if (dc.bufferedAmount > HIGH) {
      await new Promise((r) =>
        dc.addEventListener("bufferedamountlow", r, {
          once: true,
        }),
      );
    }
    dc.send(await file.slice(at, at + CHUNK).arrayBuffer());
  }
}

Connection states

connectionStateMeaningTypical reaction
newnothing started
connectingICE checks or DTLS handshake runningshow "connecting"
connectedat least one working transportstart UI, stats polling
disconnectedlost connectivity, may recover by itselfwait a few seconds
failedICE or DTLS gave uprestartIce() or tear down
closedpc.close() calledclean up
PropertyEventValues
connectionStateconnectionstatechangeaggregate of ICE + DTLS (above)
iceConnectionStateiceconnectionstatechangenew, checking, connected, completed, disconnected, failed, closed
iceGatheringStateicegatheringstatechangenew, gathering, complete
signalingStatesignalingstatechangesee Connection lifecycle
dc.readyStateopen, closeper data channel
pc.addEventListener("connectionstatechange", () => {
  switch (pc.connectionState) {
    case "failed":
      pc.restartIce();
      break;
    case "closed":
      cleanup();
      break;
  }
});
 
declare function cleanup(): void;

Stats

await pc.getStats(selector?) returns an RTCStatsReport: a map of id to stats object, each with type, id, timestamp (ms). Rates come from the delta between two samples.

typeUseful fields
outbound-rtpbytesSent, packetsSent, framesPerSecond, frameWidth, qualityLimitationReason, rid
inbound-rtpbytesReceived, packetsLost, jitter, framesDropped, jitterBufferDelay
remote-inbound-rtproundTripTime, fractionLost (as reported by the peer)
candidate-pairstate, nominated, currentRoundTripTime, availableOutgoingBitrate
local-candidate / remote-candidatecandidateType, protocol, address, port
transportselectedCandidatePairId, dtlsState, bytesSent
codecmimeType, clockRate, sdpFmtpLine
data-channelmessagesSent, bytesReceived, state
// is this call going through TURN?
async function isRelayed(pc: RTCPeerConnection) {
  const stats = await pc.getStats();
  let pair: any;
  for (const s of stats.values()) {
    if (s.type === "transport" && s.selectedCandidatePairId)
      pair = stats.get(s.selectedCandidatePairId);
  }
  // Firefox before 153 has no "transport" entry
  pair ??= [...stats.values()].find(
    (s) => s.type === "candidate-pair" && s.nominated &&
      s.state === "succeeded",
  );
  const local = pair && stats.get(pair.localCandidateId);
  return local?.candidateType === "relay";
}

Topologies & simulcast

TopologyEach client uploadsServerScales toNotes
Mesh (P2P)N−1 streamssignaling (+TURN)~4–6 peopleno media server; upload and CPU grow per peer
SFU1 stream (or simulcast set)forwards packets, no decodinghundreds per roomthe default today; E2EE possible with encoded transforms
MCU1 streamdecodes, mixes, re-encodeslarge, CPU boundone mixed stream down; latency and cost go up

Simulcast: the sender encodes the same track at several resolutions (rids); the SFU forwards the layer each subscriber can take. SVC (scalabilityMode, VP9/AV1) does it within one stream.

pc.addTransceiver(camTrack, {
  direction: "sendonly",
  sendEncodings: [
    { rid: "q", scaleResolutionDownBy: 4, maxBitrate: 15e4 },
    { rid: "h", scaleResolutionDownBy: 2, maxBitrate: 5e5 },
    { rid: "f", maxBitrate: 1.5e6 },
  ],
});
 
declare const camTrack: MediaStreamTrack;

Simulcast only makes sense toward an SFU; a plain browser peer receives just one layer.

Signaling server

Any ordered, authenticated channel works. A room-scoped WebSocket relay on Bun is enough for 1:1 and small mesh calls; see WebSockets for auth, heartbeats and scaling.

signal.ts
type Data = { room: string };
 
const server = Bun.serve({
  port: 8080,
  fetch(req, server) {
    const room = new URL(req.url).searchParams.get("room");
    if (!room) return new Response("room?", { status: 400 });
    // authenticate here (cookie, token) before upgrading
    if (server.upgrade(req, { data: { room } })) return;
    return new Response("upgrade failed", { status: 500 });
  },
  websocket: {
    data: {} as Data,
    open(ws) {
      ws.subscribe(ws.data.room);
    },
    message(ws, msg) {
      ws.publish(ws.data.room, msg); // everyone but sender
    },
  },
});
console.log(`signaling on :${server.port}`);
bun signal.ts          # dev
bun build signal.ts --target=bun --outfile=dist/signal.js

A matching client adapter, typed like the *Init dictionaries the recipes use:

signaling.ts
export type Signal =
  | { description: RTCSessionDescriptionInit }
  | { candidate: RTCIceCandidateInit };
 
export interface Signaling {
  send(msg: Signal): void;
  onMessage(fn: (msg: Signal) => void): void;
}
 
export function wsSignaling(ws: WebSocket): Signaling {
  return {
    send: (msg) => ws.send(JSON.stringify(msg)),
    onMessage: (fn) =>
      ws.addEventListener("message", (e) =>
        fn(JSON.parse(String(e.data)) as Signal),
      ),
  };
}
 
// create + send the offer or answer the state calls for
export async function describe(
  pc: RTCPeerConnection,
  sig: Signaling,
) {
  await pc.setLocalDescription();
  sig.send({ description: pc.localDescription! });
}

Validate incoming messages (e.g. with Zod) in real code; a peer can send anything.

TURN with coturn

STUN alone fails behind symmetric NATs and UDP-blocking firewalls; plan for TURN in production. coturn (opens in a new tab) is the standard open-source server.

/etc/coturn/turnserver.conf
listening-port=3478
tls-listening-port=5349
realm=turn.example.com
external-ip=203.0.113.10
min-port=49152
max-port=65535
fingerprint
use-auth-secret
static-auth-secret=CHANGE_ME
cert=/etc/ssl/turn/fullchain.pem
pkey=/etc/ssl/turn/privkey.pem
no-cli
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
docker run -d --network=host \
  -v "$PWD/turnserver.conf:/etc/coturn/turnserver.conf" \
  coturn/coturn
PortProtocolWhy
3478UDP + TCPSTUN and TURN
5349 (or 443)TLSturns:; 443 gets through strict corporate proxies
49152–65535UDPrelay allocations (min-port/max-port)

use-auth-secret enables the TURN REST scheme: your backend mints short-lived credentials from the shared secret, so none ship in the client.

turn-credentials.ts
import { createHmac } from "node:crypto";
 
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} not configured`);
  return value;
}
const secret = requireEnv("TURN_SECRET");
 
export function turnCredentials(user: string, ttl = 3600) {
  const expires = Math.floor(Date.now() / 1000) + ttl;
  const username = `${expires}:${user}`;
  const credential = createHmac("sha1", secret)
    .update(username)
    .digest("base64");
  return {
    urls: [
      "turn:turn.example.com:3478",
      "turns:turn.example.com:5349?transport=tcp",
    ],
    username,
    credential,
  };
}

Test a server with the Trickle ICE sample (opens in a new tab): a relay candidate means TURN works. Managed options: Cloudflare, Twilio, Xirsys, Metered.

Security

ConcernWhat to do
Encryptionmandatory: DTLS-SRTP for media, DTLS for data; it can't be turned off
Man in the middlethe DTLS fingerprint rides in SDP; serve signaling over TLS and authenticate it
IP leakagehost candidates use mDNS names; iceTransportPolicy: "relay" hides public IPs too
TURN abuseshort-lived REST credentials, per-user quotas, denied-peer-ip for private ranges
SFU trustthe SFU sees plaintext media unless you add E2EE via RTCRtpScriptTransform
Permissionscamera/mic need HTTPS, a user grant and allow="camera; microphone" in iframes
Untrusted inputvalidate every signaling message and data-channel payload

Debugging & pitfalls

ToolWhere
chrome://webrtc-internalslive stats graphs, SDP, ICE candidate pairs; "Create dump" to save
about:webrtcFirefox equivalent, with connection logs
Trickle ICE samplecheck that STUN/TURN servers return srflx/relay candidates
pc.getStats()log qualityLimitationReason (cpu, bandwidth) and RTT
icecandidateerror eventSTUN/TURN failures with errorCode (401 = bad credentials)
  • Remote video stays black: the element needs autoplay and playsinline; autoplay with sound needs a user gesture or muted.
  • Candidates before the description: addIceCandidate() rejects without a remote description; keep signaling ordered or queue candidates.
  • Connected on LAN, fails across networks: no TURN, or UDP blocked; add turns: on 443.
  • pc.close() does not stop the camera: call track.stop() on local tracks too.
  • Glare: both sides offer at once; use perfect negotiation instead of "caller always offers".
  • Every addTrack renegotiates: batch changes, or use replaceTrack() which never does.
  • Don't munge SDP unless you must; prefer setCodecPreferences() and setParameters().

Libraries

LibraryWhat it isPick it when
LiveKit (opens in a new tab)open-source Go SFU + livekit-client SDK, or LiveKit Cloudrooms, simulcast, recording and agents without building an SFU
mediasoup (opens in a new tab)Node.js SFU library (C++ workers) + mediasoup-clientyou want full control of the SFU inside your own server
simple-peer (opens in a new tab)thin wrapper over one RTCPeerConnectionquick 1:1 demos; unmaintained since 2023
PeerJS (opens in a new tab)wrapper + hosted broker for signalingprototypes with no backend
Pion (opens in a new tab)WebRTC in Goserver-side peers, bots, custom media servers
Janus (opens in a new tab)C media server with pluginsSIP bridging, streaming, SFU
bun add livekit-client            # npm i / pnpm add
bun add mediasoup mediasoup-client

Recipes

Data channel between two peers

A text channel over any ordered signaling channel; exactly one side passes caller = true.

import { describe, type Signaling } from "./signaling";
 
export async function connect(
  sig: Signaling, caller: boolean, cfg: RTCConfiguration,
) {
  const pc = new RTCPeerConnection(cfg);
  // negotiated: both sides create it, no datachannel event
  const dc = pc.createDataChannel("chat", {
    negotiated: true, id: 0,
  });
  pc.onicecandidate = ({ candidate }) =>
    candidate && sig.send({ candidate: candidate.toJSON() });
  sig.onMessage(async (m) => {
    if ("candidate" in m)
      return pc.addIceCandidate(m.candidate);
    const d = m.description;
    await pc.setRemoteDescription(d);
    if (d.type === "offer") await describe(pc, sig);
  });
  if (caller) await describe(pc, sig);
  await new Promise((r) => (dc.onopen = r));
  return { pc, dc }; // dc.send("hi"); dc.onmessage = ...
}

Perfect negotiation handler

Wire once per connection; after that either side can add or remove tracks and channels freely.

negotiate.ts
import { describe, type Signaling } from "./signaling";
 
export function negotiate(pc: RTCPeerConnection,
  sig: Signaling, polite: boolean) {
  let making = false, ignore = false, answering = false;
  pc.onnegotiationneeded = () => {
    making = true;
    describe(pc, sig).finally(() => (making = false));
  };
  pc.onicecandidate = ({ candidate }) =>
    candidate && sig.send({ candidate: candidate.toJSON() });
  sig.onMessage(async (m) => {
    if ("candidate" in m) return pc.addIceCandidate(
      m.candidate).catch((e) => { if (!ignore) throw e; });
    const offer = m.description.type === "offer";
    const busy = making ||
      (pc.signalingState !== "stable" && !answering);
    ignore = !polite && offer && busy;
    if (ignore) return;
    answering = !offer;
    await pc.setRemoteDescription(m.description); // rollback
    answering = false;
    if (offer) await describe(pc, sig);
  });
}

Video call sketch

Camera + mic both ways on top of the negotiation handler; hangUp releases everything.

import { negotiate } from "./negotiate";
import type { Signaling } from "./signaling";
 
export async function startCall(
  sig: Signaling, polite: boolean, cfg: RTCConfiguration,
  local: HTMLVideoElement, remote: HTMLVideoElement,
) {
  const pc = new RTCPeerConnection(cfg);
  negotiate(pc, sig, polite);
  const inbound = new MediaStream();
  remote.srcObject = inbound; // <video autoplay playsinline>
  pc.ontrack = ({ track }) => inbound.addTrack(track);
  const cam = await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: { width: 1280, height: 720 },
  });
  local.srcObject = cam; // local preview must be muted
  for (const t of cam.getTracks()) pc.addTrack(t, cam);
  return function hangUp() {
    for (const t of cam.getTracks()) t.stop();
    pc.close();
  };
}

Bitrate monitor with getStats

Polls outbound video every second and reports kbit/s (bits per millisecond equals kbit/s).

export function watchBitrate(
  pc: RTCPeerConnection,
  onKbps: (kbps: number) => void,
  everyMs = 1000,
) {
  let prev: { bytes: number; ts: number } | undefined;
  const id = setInterval(async () => {
    let bytes = 0, ts = 0;
    for (const s of (await pc.getStats()).values()) {
      if (s.type !== "outbound-rtp") continue;
      if (s.kind !== "video") continue;
      bytes += s.bytesSent ?? 0; // sums simulcast layers
      ts = Math.max(ts, s.timestamp);
    }
    if (prev && ts > prev.ts) {
      onKbps(((bytes - prev.bytes) * 8) / (ts - prev.ts));
    }
    prev = { bytes, ts };
  }, everyMs);
  return () => clearInterval(id);
}

References