+ Want these numbers from your connection?{" "}
+
+ Run the browser RPC speed test →
+ {" "}
+
+ Paste any endpoints (keyed included), no install, URLs never leave your browser.
+
+
+
+
{keyedRpcSpecs.length > 0 && (
diff --git a/src/app/speedtest-rpc/page.tsx b/src/app/speedtest-rpc/page.tsx
new file mode 100644
index 00000000..f0453143
--- /dev/null
+++ b/src/app/speedtest-rpc/page.tsx
@@ -0,0 +1,95 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { pageMetadata } from "@/lib/page-metadata";
+import { SpeedtestRpcClient } from "@/components/speedtest/speedtest-rpc-client";
+import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld";
+import { SITE } from "@/data/site";
+
+export const metadata: Metadata = pageMetadata({
+ path: "/speedtest-rpc",
+ title: "RPC Speed Test — benchmark your endpoints from your browser",
+ description:
+ "Paste any RPC URLs and measure their real latency from your own connection. Same anti-cache methodology as our public benchmarks. No install, no signup — your URLs and API keys never leave your browser.",
+});
+
+export const revalidate = 3600;
+
+export default function SpeedtestRpcPage() {
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ buildBreadcrumbJsonLd([
+ { name: "Home", item: SITE.url },
+ { name: "RPC Speed Test", item: `${SITE.url}/speedtest-rpc` },
+ ]),
+ {
+ "@type": "WebApplication",
+ "@id": `${SITE.url}/speedtest-rpc#app`,
+ name: "RPC Speed Test",
+ url: `${SITE.url}/speedtest-rpc`,
+ applicationCategory: "DeveloperApplication",
+ operatingSystem: "Web",
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
+ description:
+ "Browser-based latency test for JSON-RPC endpoints. Probes run directly from the visitor's connection with the same anti-cache methodology as OpenChainBench's public benchmarks; endpoint URLs never reach OpenChainBench servers.",
+ publisher: { "@id": `${SITE.url}/#org` },
+ },
+ ],
+ };
+
+ return (
+
+
+
+ RPC speed test
+
+
+ Benchmark your RPC endpoints, from your connection
+
+
+ Paste any JSON-RPC URLs — public gateways or your own keyed endpoints —
+ pick a duration, and watch them race. Every probe fires directly from
+ your browser with the same anti-cache payload as our{" "}
+
+ public RPC benchmarks
+
+ , so the numbers are comparable to the leaderboards.
+
+
+ No install, no signup. Your URLs and API keys never leave this browser
+ tab — requests go straight from you to the provider.
+
+
+
+
+
+
+ Methodology
+
+
+ Each endpoint receives an identical{" "}
+
+ eth_getBlockByNumber("latest", false)
+ {" "}
+ POST with a rotating request id, so no edge cache can answer without
+ touching a real node. One warmup request absorbs the TCP + TLS
+ handshake, then endpoints are probed in randomized round-robin order
+ until the clock runs out — network wake-ups hit every endpoint
+ evenly. Responses are classified ok / http_err / jsonrpc_err /
+ timeout, and an endpoint reporting a block more than 20 behind the
+ best tip in the same round is flagged stale. Latency percentiles use
+ ok responses only, exactly like the{" "}
+
+ public benchmark methodology
+
+ . Results reflect your device, network and location — that is the
+ point: it is the latency your application would actually see.
+
+
+
+ );
+}
diff --git a/src/components/speedtest/speedtest-rpc-client.tsx b/src/components/speedtest/speedtest-rpc-client.tsx
new file mode 100644
index 00000000..28cff19e
--- /dev/null
+++ b/src/components/speedtest/speedtest-rpc-client.tsx
@@ -0,0 +1,692 @@
+"use client";
+
+/**
+ * Client-side RPC speed test. The Speedtest.net moment for RPC endpoints:
+ * the user pastes any number of RPC URLs, picks a duration, and the
+ * browser probes them directly — same anti-cache methodology as the
+ * public OCB harnesses (eth_getBlockByNumber("latest", false) with a
+ * rotating JSON-RPC id, ok/http_err/jsonrpc_err/stale/timeout
+ * classification).
+ *
+ * Everything runs in the user's browser from the user's IP. URLs (and
+ * any embedded API keys) never touch OCB servers — requests go straight
+ * browser → provider. Zero server cost per test by construction.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+// ─── Types ───────────────────────────────────────────────────────────
+
+type Verdict = "ok" | "http_err" | "jsonrpc_err" | "timeout";
+
+type Sample = {
+ ms: number;
+ verdict: Verdict;
+ block: number | null;
+ round: number;
+};
+
+type EpStatus =
+ | "pending"
+ | "checking"
+ | "ready"
+ | "cors_blocked"
+ | "invalid"
+ | "testing"
+ | "done";
+
+type Endpoint = {
+ id: string;
+ url: string;
+ host: string;
+ status: EpStatus;
+ chainId: string | null;
+ samples: Sample[];
+ staleRounds: number;
+ lastMs: number | null;
+ error?: string;
+};
+
+type Stage = "setup" | "testing" | "results";
+
+// ─── Probe engine ────────────────────────────────────────────────────
+
+const PROBE_TIMEOUT_MS = 5_000;
+const STALE_BLOCKS = 20;
+
+let idCounter = 0;
+function rpcId(): string {
+ // Rotating id defeats body-keyed edge caches — identical to the
+ // harness payload contract documented on /methodology.
+ idCounter += 1;
+ return `ocb-st-${Date.now().toString(36)}-${idCounter}`;
+}
+
+async function rpcCall(
+ url: string,
+ method: string,
+ params: unknown[],
+): Promise<{ ms: number; verdict: Verdict; result: unknown; block: number | null }> {
+ const body = JSON.stringify({ jsonrpc: "2.0", id: rpcId(), method, params });
+ const t0 = performance.now();
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body,
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ // Never send cookies/credentials to third-party RPC endpoints.
+ credentials: "omit",
+ cache: "no-store",
+ });
+ const ms = performance.now() - t0;
+ if (!res.ok) return { ms, verdict: "http_err", result: null, block: null };
+ let json: { result?: { number?: string } | string; error?: unknown };
+ try {
+ json = await res.json();
+ } catch {
+ return { ms, verdict: "jsonrpc_err", result: null, block: null };
+ }
+ if (json.error || json.result == null)
+ return { ms, verdict: "jsonrpc_err", result: null, block: null };
+ let block: number | null = null;
+ if (typeof json.result === "object" && typeof json.result.number === "string") {
+ block = parseInt(json.result.number, 16);
+ if (Number.isNaN(block)) block = null;
+ }
+ return { ms, verdict: "ok", result: json.result, block };
+ } catch (e) {
+ const ms = performance.now() - t0;
+ if (e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")) {
+ return { ms, verdict: "timeout", result: null, block: null };
+ }
+ // TypeError from fetch = network-level failure. From a browser this
+ // is almost always CORS (the response exists but is opaque to us).
+ throw e;
+ }
+}
+
+function quantile(sorted: number[], q: number): number {
+ if (sorted.length === 0) return NaN;
+ const pos = (sorted.length - 1) * q;
+ const lo = Math.floor(pos);
+ const hi = Math.ceil(pos);
+ if (lo === hi) return sorted[lo];
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
+}
+
+function stats(ep: Endpoint) {
+ const okMs = ep.samples.filter((s) => s.verdict === "ok").map((s) => s.ms).sort((a, b) => a - b);
+ const n = ep.samples.length;
+ const ok = okMs.length;
+ return {
+ n,
+ ok,
+ successPct: n === 0 ? 0 : Math.round((ok / n) * 100),
+ p50: quantile(okMs, 0.5),
+ p90: quantile(okMs, 0.9),
+ min: okMs[0] ?? NaN,
+ max: okMs[okMs.length - 1] ?? NaN,
+ };
+}
+
+// ─── Gauge geometry ──────────────────────────────────────────────────
+
+/** Log-ish scale stops mapped onto a 240° arc, speedometer-style. */
+const GAUGE_STOPS = [1, 5, 10, 25, 50, 100, 250, 500, 1000];
+const GAUGE_START = -210; // degrees; sweep 240° to +30
+const GAUGE_SWEEP = 240;
+
+function msToAngle(ms: number): number {
+ const clamped = Math.max(1, Math.min(1000, ms));
+ const t = Math.log(clamped / 1) / Math.log(1000 / 1); // 0..1 log scale
+ return GAUGE_START + t * GAUGE_SWEEP;
+}
+
+function polar(cx: number, cy: number, r: number, deg: number): [number, number] {
+ const rad = (deg * Math.PI) / 180;
+ return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
+}
+
+function arcPath(cx: number, cy: number, r: number, a0: number, a1: number): string {
+ const [x0, y0] = polar(cx, cy, r, a0);
+ const [x1, y1] = polar(cx, cy, r, a1);
+ const large = a1 - a0 > 180 ? 1 : 0;
+ return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
+}
+
+function msColor(ms: number): string {
+ if (ms < 50) return "var(--color-good)";
+ if (ms < 200) return "var(--color-warn)";
+ return "#ef4444";
+}
+
+function Gauge({
+ ms,
+ label,
+ sub,
+}: {
+ ms: number | null;
+ label: string;
+ sub: string;
+}) {
+ const angle = ms == null ? GAUGE_START : msToAngle(ms);
+ return (
+
+
+
+
+ {ms == null ? "—" : Math.round(ms)}
+ ms
+
+
{label}
+
{sub}
+
+
+ );
+}
+
+// ─── Main component ──────────────────────────────────────────────────
+
+const DURATIONS = [
+ { sec: 15, label: "15 s · quick" },
+ { sec: 30, label: "30 s · standard" },
+ { sec: 60, label: "60 s · thorough" },
+];
+
+const EXAMPLE_URLS = [
+ "https://ethereum-rpc.publicnode.com",
+ "https://eth.llamarpc.com",
+ "https://1rpc.io/eth",
+];
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
+
+export function SpeedtestRpcClient() {
+ const [stage, setStage] = useState("setup");
+ const [inputs, setInputs] = useState(["", ""]);
+ const [durationSec, setDurationSec] = useState(30);
+ const [endpoints, setEndpoints] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [elapsed, setElapsed] = useState(0);
+ const [round, setRound] = useState(0);
+ const abortRef = useRef<{ stop: boolean }>({ stop: false });
+
+ const setInput = (i: number, v: string) =>
+ setInputs((xs) => xs.map((x, j) => (j === i ? v : x)));
+ const addInput = () => setInputs((xs) => [...xs, ""]);
+ const removeInput = (i: number) =>
+ setInputs((xs) => (xs.length <= 1 ? xs : xs.filter((_, j) => j !== i)));
+
+ const validCount = inputs.filter((u) => {
+ try {
+ const p = new URL(u.trim());
+ return p.protocol === "https:" && !p.username && !p.password;
+ } catch {
+ return false;
+ }
+ }).length;
+
+ // ── Test orchestration ────────────────────────────────────────────
+ const start = useCallback(async () => {
+ const urls = Array.from(
+ new Set(
+ inputs
+ .map((u) => u.trim())
+ .filter((u) => {
+ try {
+ const p = new URL(u);
+ return p.protocol === "https:" && !p.username && !p.password;
+ } catch {
+ return false;
+ }
+ }),
+ ),
+ );
+ if (urls.length === 0) return;
+ abortRef.current = { stop: false };
+ const eps: Endpoint[] = urls.map((url, i) => ({
+ id: `ep${i}`,
+ url,
+ host: hostOf(url),
+ status: "checking",
+ chainId: null,
+ samples: [],
+ staleRounds: 0,
+ lastMs: null,
+ }));
+ setEndpoints(eps);
+ setStage("testing");
+ setElapsed(0);
+ setRound(0);
+
+ const patch = (id: string, p: Partial) =>
+ setEndpoints((xs) => xs.map((e) => (e.id === id ? { ...e, ...p } : e)));
+
+ // Phase 1: reachability + chain detection (eth_chainId), sequential
+ // so the gauge can narrate each endpoint as it comes online.
+ for (const ep of eps) {
+ if (abortRef.current.stop) return;
+ setActiveId(ep.id);
+ try {
+ const r = await rpcCall(ep.url, "eth_chainId", []);
+ const chainId =
+ r.verdict === "ok" && typeof r.result === "string" ? r.result : null;
+ ep.chainId = chainId;
+ ep.status = "ready";
+ patch(ep.id, { chainId, status: "ready", lastMs: r.ms });
+ } catch {
+ ep.status = "cors_blocked";
+ patch(ep.id, {
+ status: "cors_blocked",
+ error:
+ "Endpoint rejected the browser request (CORS or network). Test it from a terminal instead — see the curl below.",
+ });
+ }
+ }
+ const live = eps.filter((e) => e.status === "ready");
+ if (live.length === 0) {
+ setStage("results");
+ return;
+ }
+
+ // Phase 2: one warmup each (absorbs TCP+TLS so measured rounds see
+ // steady-state round trips, same reasoning as the harness).
+ for (const ep of live) {
+ if (abortRef.current.stop) return;
+ setActiveId(ep.id);
+ patch(ep.id, { status: "testing" });
+ try {
+ const w = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]);
+ patch(ep.id, { lastMs: w.ms });
+ } catch {
+ /* keep it in the pool; measured rounds will classify */
+ }
+ }
+
+ // Phase 3: measured rounds until the clock runs out. Order is
+ // re-shuffled every round so network wake-ups hit endpoints evenly.
+ const tEnd = performance.now() + durationSec * 1000;
+ const t0 = performance.now();
+ let roundNo = 0;
+ const timer = setInterval(
+ () => setElapsed(Math.min(durationSec, (performance.now() - t0) / 1000)),
+ 200,
+ );
+ try {
+ while (performance.now() < tEnd && !abortRef.current.stop) {
+ roundNo += 1;
+ setRound(roundNo);
+ const order = [...live].sort(() => Math.random() - 0.5);
+ const roundBlocks: Record = {};
+ for (const ep of order) {
+ if (performance.now() >= tEnd || abortRef.current.stop) break;
+ setActiveId(ep.id);
+ let sample: Sample;
+ try {
+ const r = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]);
+ sample = { ms: r.ms, verdict: r.verdict, block: r.block, round: roundNo };
+ } catch {
+ sample = { ms: PROBE_TIMEOUT_MS, verdict: "timeout", block: null, round: roundNo };
+ }
+ roundBlocks[ep.id] = sample.block;
+ ep.samples.push(sample);
+ patch(ep.id, { samples: [...ep.samples], lastMs: sample.ms });
+ // Politeness gap so we never hammer a provider.
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ // Stale detection: an endpoint more than STALE_BLOCKS behind the
+ // best tip seen this round is serving an old head.
+ const tips = Object.values(roundBlocks).filter((b): b is number => b != null);
+ if (tips.length >= 2) {
+ const best = Math.max(...tips);
+ for (const ep of live) {
+ const b = roundBlocks[ep.id];
+ if (b != null && best - b > STALE_BLOCKS) {
+ ep.staleRounds += 1;
+ patch(ep.id, { staleRounds: ep.staleRounds });
+ }
+ }
+ }
+ }
+ } finally {
+ clearInterval(timer);
+ }
+ for (const ep of live) patch(ep.id, { status: "done" });
+ setActiveId(null);
+ // Small beat before the reveal — lets the last needle move land.
+ await new Promise((r) => setTimeout(r, 650));
+ setStage("results");
+ }, [inputs, durationSec]);
+
+ const stop = useCallback(() => {
+ abortRef.current.stop = true;
+ }, []);
+
+ useEffect(() => () => { abortRef.current.stop = true; }, []);
+
+ const active = endpoints.find((e) => e.id === activeId) ?? null;
+ const ranked = useMemo(() => {
+ const done = endpoints.filter((e) => e.samples.length > 0);
+ return done
+ .map((e) => ({ ep: e, s: stats(e) }))
+ .sort((a, b) => (Number.isNaN(a.s.p50) ? 1 : Number.isNaN(b.s.p50) ? -1 : a.s.p50 - b.s.p50));
+ }, [endpoints]);
+ const maxP50 = Math.max(...ranked.map((r) => (Number.isNaN(r.s.p50) ? 0 : r.s.p50)), 1);
+
+ const reset = () => {
+ abortRef.current.stop = true;
+ setStage("setup");
+ setEndpoints([]);
+ setActiveId(null);
+ setElapsed(0);
+ };
+
+ return (
+
+ Same probe and classification as the public benchmarks
+ (rotating-id anti-cache, ok / http_err / jsonrpc_err / stale /
+ timeout). Your URLs never left this browser tab.
+