diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 10c2eeb1..c1466a9f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,3 +47,5 @@ jobs:
- name: Build OpenNext Worker
working-directory: apps/web
run: npx opennextjs-cloudflare build
+ - name: Check production prefetch behavior
+ run: npm run test:prefetch -w xcp-explorer-web
diff --git a/apps/web/package.json b/apps/web/package.json
index 4d924cbb..5c05f2f0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,6 +9,7 @@
"start": "next start",
"lint": "eslint",
"test:e2e": "playwright test",
+ "test:prefetch": "playwright test --config playwright.prefetch.config.ts",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
},
"dependencies": {
diff --git a/apps/web/playwright.prefetch.config.ts b/apps/web/playwright.prefetch.config.ts
new file mode 100644
index 00000000..9399d8a1
--- /dev/null
+++ b/apps/web/playwright.prefetch.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// Prefetch is disabled by Next in development. Run after the production build.
+export default defineConfig({
+ testDir: "./tests/prefetch",
+ timeout: 45_000,
+ expect: { timeout: 10_000 },
+ workers: 1,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: "list",
+ use: {
+ ...devices["Desktop Chrome"],
+ hasTouch: true,
+ baseURL: "http://127.0.0.1:3108",
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ },
+ webServer: {
+ command: "npm run start -- --hostname 127.0.0.1 --port 3108",
+ url: "http://127.0.0.1:3108",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+});
diff --git a/apps/web/src/features/records/cells.tsx b/apps/web/src/features/records/cells.tsx
index 80a51c50..0a256a5b 100644
--- a/apps/web/src/features/records/cells.tsx
+++ b/apps/web/src/features/records/cells.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { commas, short, ts, timeAgo } from "@/lib/format";
import { AssetIcon } from "@/components/ui/badges";
import { assetHref } from "@/lib/asset-link";
+import { RecordLink } from "@/features/records/components/record-link";
/** The page subject a record table renders under — drives contextual column suppression (R4:
* never render the column the page already answers) and perspective signing of quantities.
@@ -55,13 +56,13 @@ export const blockCell = (n?: number | null) =>
) : (
—
);
-export const txCell = (h?: string | null) => (h ? {mono(short(h))} : "—");
+export const txCell = (h?: string | null) => (h ? {mono(short(h))} : "—");
/** Address — single-line ellipsis over the FULL string, complete address in title. Never 4+4 (R5). */
export const addrCell = (a?: string | null) =>
a ? (
-
+
{a}
-
+
) : (
"—"
);
@@ -69,9 +70,14 @@ export const addrCell = (a?: string | null) =>
* table, where two address columns share a row and the front-ellipsis form was unreadable. */
export const addrEndsCell = (a?: string | null) =>
a ? (
-
+
{short(a, 8, 6)}
-
+
) : (
"—"
);
@@ -110,9 +116,9 @@ export const timeCell = (t?: number | null) =>
/** The row's trailing action — the quiet View link, sr-only header. */
export const viewCell = (h?: string | null) =>
h ? (
-
+
View
-
+
) : (
—
);
diff --git a/apps/web/src/features/records/components/record-link.tsx b/apps/web/src/features/records/components/record-link.tsx
new file mode 100644
index 00000000..d55f6d98
--- /dev/null
+++ b/apps/web/src/features/records/components/record-link.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import type { Route } from "next";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useRef, type ComponentProps } from "react";
+
+type Props = Omit, "href" | "prefetch"> & { href: Route };
+
+/** Dense tables can expose hundreds of address/transaction links. Warm the destination
+ * on intent, once per href, without making every visible row render on the server. */
+export function RecordLink({ href, onMouseEnter, onFocus, onTouchStart, ...props }: Props) {
+ const router = useRouter();
+ const prefetched = useRef(null);
+ const prefetch = () => {
+ if (prefetched.current === href) return;
+ prefetched.current = href;
+ router.prefetch(href);
+ };
+
+ return (
+ {
+ onMouseEnter?.(event);
+ if (!event.defaultPrevented) prefetch();
+ }}
+ onFocus={(event) => {
+ onFocus?.(event);
+ if (!event.defaultPrevented) prefetch();
+ }}
+ onTouchStart={(event) => {
+ onTouchStart?.(event);
+ if (!event.defaultPrevented) prefetch();
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/features/records/registry.tsx b/apps/web/src/features/records/registry.tsx
index 7d075e59..ec8a0e30 100644
--- a/apps/web/src/features/records/registry.tsx
+++ b/apps/web/src/features/records/registry.tsx
@@ -1,5 +1,6 @@
import { dispenseSats } from "@/lib/dispenser-pricing";
import Link from "next/link";
+import { RecordLink } from "@/features/records/components/record-link";
import type {
PoolRow,
RecordKind,
@@ -595,9 +596,9 @@ export const REGISTRY: Registry = {
if (!Number.isFinite(paid) || !paid || !qty) return "—";
const unit = sats(paid / qty);
return r.dispenser_tx_hash ? (
-
+
{unit}
-
+
) : (
unit
);
@@ -809,9 +810,9 @@ export const REGISTRY: Registry = {
w: "130px",
cell: (r) =>
r.order_match_id ? (
-
+
{short(r.order_match_id, 8, 6)}
-
+
) : (
"—"
),
diff --git a/apps/web/tests/prefetch/record-links.spec.ts b/apps/web/tests/prefetch/record-links.spec.ts
new file mode 100644
index 00000000..ef72ba2a
--- /dev/null
+++ b/apps/web/tests/prefetch/record-links.spec.ts
@@ -0,0 +1,93 @@
+import { test, expect, type Page } from "@playwright/test";
+import type { SendRow } from "@xcp/shared/records";
+
+const ADDRESS = "1K8pj6raDJ2GvUnXzE78HFVES31LMuxeTv";
+const NEXT_ADDRESS = "171grbn6H9uZRc5LVkgtSR2aFosLh8iqEw";
+const TX = "b5b0ee151fcf23d8612f816df969ae59bac7e789296ee51c11a34af3bee36705";
+
+async function openTable(page: Page) {
+ const prefetches: string[] = [];
+ const apiReads: string[] = [];
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("request", (request) => {
+ const path = new URL(request.url()).pathname;
+ if (request.headers()["next-router-prefetch"] && /^\/(address|tx)\//.test(path)) prefetches.push(path);
+ });
+ await page.route("https://api.xcp.io/**", (route) => {
+ const url = new URL(route.request().url());
+ apiReads.push(url.pathname);
+ if (url.pathname !== "/v2/sends") return route.fulfill({ status: 503, body: "unavailable" });
+ const source = Number(url.searchParams.get("offset")) ? NEXT_ADDRESS : ADDRESS;
+ const row: SendRow = {
+ tx_hash: TX,
+ block_index: 967626,
+ block_time: null,
+ source,
+ destination: null,
+ asset: null,
+ quantity_normalized: "1",
+ send_type: "enhanced_send",
+ status: "valid",
+ memo: null,
+ };
+ return route.fulfill({ json: { result: Array.from({ length: 30 }, () => row), next_offset: 50 } });
+ });
+ await page.route("https://cdn.usefathom.com/**", (route) => route.fulfill({ status: 204 }));
+ await page.goto("/sends", { waitUntil: "domcontentloaded" });
+ await expect(page.getByRole("table").getByRole("row")).toHaveCount(31);
+ await page.getByRole("button", { name: "Next", exact: true }).scrollIntoViewIfNeeded();
+ await page.waitForTimeout(1_000);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.waitForTimeout(1_000);
+ // A bounded quiet window is necessary to detect unwanted background work.
+ expect(prefetches).toEqual([]);
+ return { prefetches, apiReads, errors };
+}
+
+for (const intent of ["hover", "keyboard", "touch"] as const) {
+ test(`${intent} warms only the chosen record and navigation still works`, async ({ page }) => {
+ if (intent === "touch") await page.setViewportSize({ width: 390, height: 844 });
+ const { prefetches, apiReads, errors } = await openTable(page);
+ const href = intent === "touch" ? `/tx/${TX}` : `/address/${ADDRESS}`;
+ const link = page.locator(`main a[href="${href}"]`).first();
+ if (intent === "hover") await link.hover();
+ if (intent === "keyboard") await link.focus();
+ if (intent === "touch") await link.dispatchEvent("touchstart");
+ await expect.poll(() => prefetches.length).toBeGreaterThan(0);
+ await page.waitForTimeout(1_000);
+ expect([...new Set(prefetches)]).toEqual([href]);
+ const warmed = prefetches.length;
+ await link.dispatchEvent("mouseenter");
+ await link.dispatchEvent("focus");
+ await link.dispatchEvent("touchstart");
+ await page.waitForTimeout(1_000);
+ expect(prefetches).toHaveLength(warmed);
+
+ if (intent === "keyboard") await page.keyboard.press("Enter");
+ else if (intent === "touch") await link.tap();
+ else await link.click();
+ await expect(page).toHaveURL(new RegExp(`${href}$`));
+ await expect(page.locator("main")).toBeVisible();
+ if (intent !== "touch") {
+ await expect(page.getByRole("heading", { name: ADDRESS, exact: true })).toBeVisible();
+ await expect.poll(() => apiReads.includes(`/v2/addresses/${ADDRESS}/summary`)).toBe(true);
+ }
+ expect(errors).toEqual([]);
+ });
+}
+
+test("pagination does not inherit prefetch intent from the previous address", async ({ page }) => {
+ const { prefetches, errors } = await openTable(page);
+ await page.locator(`main a[href="/address/${ADDRESS}"]`).first().hover();
+ await expect.poll(() => prefetches.length).toBeGreaterThan(0);
+ await page.getByRole("button", { name: "Next", exact: true }).click();
+ const link = page.locator(`main a[href="/address/${NEXT_ADDRESS}"]`).first();
+ await expect(link).toHaveCount(1);
+ await link.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(1_000);
+ expect(prefetches).not.toContain(`/address/${NEXT_ADDRESS}`);
+ await link.focus();
+ await expect.poll(() => prefetches.includes(`/address/${NEXT_ADDRESS}`)).toBe(true);
+ expect(errors).toEqual([]);
+});
diff --git a/docs/frontend-prefetch-budget.md b/docs/frontend-prefetch-budget.md
new file mode 100644
index 00000000..c8efc60d
--- /dev/null
+++ b/docs/frontend-prefetch-budget.md
@@ -0,0 +1,67 @@
+# Record navigation resource budget
+
+Dense tables prefetch address and transaction destinations only on mouse hover,
+keyboard focus, or touch start. `RecordLink` retains Next's normal link navigation
+and calls `router.prefetch` once per selected href. Repeated intent events do not
+restart the warm-up; a changed href after pagination can warm independently.
+
+This applies to the shared address/transaction cells and the dispenser/order-match
+transaction links in the record registry. Asset and block links keep their existing
+behavior. Data polling, API caching, server rendering, and loading/error states are
+unchanged. Very fast clicks have less prefetch head start, so production navigation
+latency must be checked alongside resource savings.
+
+## Evidence, September 19, 2026 UTC
+
+A 25-second live `xcp-web` tail sample at 01:11 UTC contained:
+
+| Destination | Requests | Requests marked `next-router-prefetch` | CPU spent on those prefetches |
+| --- | ---: | ---: | ---: |
+| Address | 279 | 237 (85%) | 3,821 ms |
+| Transaction | 340 | 213 (63%) | 2,753 ms |
+
+All those prefetch responses were HTTP 200. Tail redacts many address/transaction
+paths: distinct-path counts cannot establish cache reuse or traffic provenance.
+This short sample identifies an optimization target, not a monthly savings estimate.
+
+The local before/after comparison used a production OpenNext build, Chromium at
+1440×1000, the same captured 50-row Sends API response, and the same four scroll
+positions with timed observation windows. The baseline was main at `a1693910`.
+
+| Observation | Before | After |
+| --- | ---: | ---: |
+| Automatic address prefetch requests | 1,153 | 0 |
+| Automatic transaction prefetch requests | 31 | 0 |
+| Automatic block/asset prefetch requests | 42 | 68 |
+| Total automatic prefetch requests | 1,226 | 68 |
+| Click to address heading after hover | 373 ms | 378 ms |
+| Browser exceptions | 0 | 0 |
+
+The chosen address still warmed on hover (two RSC requests in the after run).
+The large baseline address count included repeated requests, not 1,153 distinct
+addresses. Background scheduling and available connection capacity affect counts
+for the unchanged block/asset links. Click timing is one local observation, not a
+latency benchmark or production SLO. The measured 94.5% reduction in total automatic
+prefetch requests applies to this table exercise, not all Worker traffic or dollars.
+
+Desktop screenshots retained the same layout; mobile at 390×844 had no horizontal
+overflow and all 50 View links remained available. Fresh address API reads still
+start after navigation, including when the API is unavailable.
+
+## Regression checks
+
+`npm run test:prefetch -w xcp-explorer-web` runs after a production build; development
+mode does not exercise Next's automatic prefetch behavior. CI runs it after the
+OpenNext build. The four browser cases check no viewport/scroll fan-out, hover,
+keyboard Enter, mobile touch/tap, repeated intent, changed destinations after
+pagination, browser errors, and fresh address reads after navigation.
+
+Also run `npm run check`, the existing web E2E/accessibility suite, the OpenNext
+build, and a Wrangler dry run. Before owner-approved deployment, record the current
+Worker version for rollback. After deployment, compare same-duration request/CPU
+windows, navigation responses/latency, HTTP errors, D1 usage, and R2 operations.
+Separate prefetch requests from actual navigations and retain the longer matched
+traffic-window comparison before claiming recurring savings.
+
+Next's supported manual-prefetch API is documented in its
+[prefetching guide](https://nextjs.org/docs/app/guides/prefetching).