diff --git a/PHASE2-FINDINGS.md b/PHASE2-FINDINGS.md
index 2bb4b9031..9e549fab1 100644
--- a/PHASE2-FINDINGS.md
+++ b/PHASE2-FINDINGS.md
@@ -213,6 +213,34 @@ payload to a stub hook that is byte-identical for profiling and production build
that probe returns a false negative for both. Confirm the Profiler tab with the real
extension.
+## Where the re-render churn actually comes from
+
+`scripts/benchmarks/measure-rerenders.mts` counts React commits on a board nobody is
+touching, and attributes each commit to the shallowest component that re-rendered — the
+point where the update entered the tree.
+
+**An idle board commits 73–96 times per minute**, and in 60 s `@mantine/core/Box`
+re-renders over 30,000 times. That is the churn behind the 650 MB peak: it is transient
+allocation, not retention.
+
+Roughly 80% of those commits enter at the *same place*: an anonymous provider at depth 2
+whose props are `[value, children]` and whose value is `{appDir}` — Next.js's internal
+`HeadManagerContext`. Its value is a fresh object literal on every App Router render, so
+each one re-renders the entire application below it. The remainder enter around
+`__next_metadata_boundary__` / `
`, which points the same way.
+
+So the dominant cost is the **App Router root re-rendering about once a second**, not any
+individual widget. No polling `router.refresh()` exists in application code, so the
+trigger is inside Next's own router/metadata handling and is not fixed by anything in
+this PR. That is the next thing to chase, and it is worth more than any further
+component-level tuning: everything else is downstream of it.
+
+`useTimeAgo` was calling `setState` every second even when the rendered label was
+unchanged ("3 minutes ago" holds for a minute), so it now compares before setting. This
+is a correctness fix — idle commits stayed within their 73–96 range afterwards, because
+the app-root churn dominates and this hook is not what drives it. Recorded as *not* a
+measured win.
+
## Measured but not pursued
| lever | measured cost | why not pursued |
diff --git a/packages/common/src/hooks.ts b/packages/common/src/hooks.ts
index 969c04c43..ecae6faa0 100644
--- a/packages/common/src/hooks.ts
+++ b/packages/common/src/hooks.ts
@@ -18,7 +18,14 @@ export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
}, [timestamp]);
useEffect(() => {
- const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
+ // Only re-render when the rendered text actually changes. The label is coarse
+ // ("3 minutes ago" holds for a whole minute) but the timer ticks every second,
+ // so setting state unconditionally re-rendered the subtree ~60x/minute per
+ // consumer for an identical string.
+ const intervalId = setInterval(() => {
+ const next = calculateTimeAgo(timestamp);
+ setTimeAgo((previous) => (previous === next ? previous : next));
+ }, updateFrequency);
return () => clearInterval(intervalId); // clear interval on hook unmount
}, [timestamp, updateFrequency]);
diff --git a/scripts/benchmarks/measure-rerenders.mts b/scripts/benchmarks/measure-rerenders.mts
new file mode 100644
index 000000000..bfbb394e1
--- /dev/null
+++ b/scripts/benchmarks/measure-rerenders.mts
@@ -0,0 +1,175 @@
+/**
+ * Counts React commits and per-component re-renders on an idle board.
+ *
+ * The 650 MB browser heap was mostly transient allocation, i.e. re-render churn rather
+ * than retention, so the number that matters is how often components re-render while
+ * nobody is touching the page. Hooks into the devtools commit callback and attributes
+ * each commit to the components that actually re-rendered in it.
+ *
+ * STRESS_BASE_URL=http://127.0.0.1:PORT \
+ * node --experimental-strip-types scripts/benchmarks/measure-rerenders.mts
+ */
+import { chromium } from "@playwright/test";
+
+const baseUrl = process.env.STRESS_BASE_URL;
+if (!baseUrl) throw new Error("STRESS_BASE_URL is required");
+const username = process.env.STRESS_USERNAME ?? "demo";
+const password = process.env.STRESS_PASSWORD ?? "demodemo";
+const boardName = process.env.STRESS_BOARD ?? "default";
+const observeMs = Number(process.env.RERENDER_OBSERVE_MS ?? 60_000);
+
+const browser = await chromium.launch();
+const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
+
+await context.addInitScript(() => {
+ const state = { commits: 0, byComponent: new Map(), roots: new Map(), started: 0 };
+ (globalThis as unknown as Record).__homarrRenderStats = state;
+
+ const nameOf = (fiber: { type?: unknown; elementType?: unknown }) => {
+ const type = (fiber.type ?? fiber.elementType) as
+ | string
+ | { displayName?: string; name?: string }
+ | null
+ | undefined;
+ if (typeof type === "string") return `<${type}>`;
+ if (typeof type === "function" || (type && typeof type === "object")) {
+ return (
+ (type as { displayName?: string; name?: string }).displayName ??
+ (type as { name?: string }).name ??
+ "(anonymous)"
+ );
+ }
+ return null;
+ };
+
+ (globalThis as unknown as Record).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
+ renderers: new Map(),
+ supportsFiber: true,
+ inject: () => 1,
+ onCommitFiberUnmount: () => undefined,
+ onPostCommitFiberRoot: () => undefined,
+ onCommitFiberRoot: (_id: unknown, root: { current?: unknown }) => {
+ if (!state.started) return;
+ state.commits++;
+ // Walk only fibers React flagged as updated in this commit.
+ const seen = new Set();
+ // Shallowest re-rendering component in this commit — i.e. where the update
+ // entered the tree. Everything below it re-rendered as a consequence.
+ let shallowest: { name: string; depth: number; fiber: Record } | null = null;
+ const walk = (fiber: Record | null | undefined, depth: number) => {
+ if (!fiber || seen.has(fiber) || depth > 2000) return;
+ seen.add(fiber);
+ const alternate = fiber.alternate as Record | null;
+ // A fiber re-rendered if it committed new work over a previous version.
+ if (alternate && fiber.actualDuration !== undefined && (fiber.actualDuration as number) > 0) {
+ const name = nameOf(fiber as never);
+ if (name) {
+ state.byComponent.set(name, (state.byComponent.get(name) ?? 0) + 1);
+ if (!shallowest || depth < shallowest.depth) shallowest = { name, depth, fiber };
+ }
+ }
+ walk(fiber.child as never, depth + 1);
+ walk(fiber.sibling as never, depth);
+ };
+ walk((root.current ?? null) as never, 0);
+ if (shallowest) {
+ const found = shallowest as { name: string; depth: number; fiber?: Record };
+ // Anonymous components are common; the ancestor chain identifies them.
+ const chain: string[] = [];
+ let up = found.fiber?.return as Record | undefined;
+ for (let i = 0; i < 8 && up; i++, up = up.return as Record | undefined) {
+ const name = nameOf(up as never);
+ if (name) chain.push(name);
+ }
+ // At the very top the ancestor chain is empty, so describe it by what it
+ // renders and which props it takes instead.
+ const descendants: string[] = [];
+ let down = found.fiber?.child as Record | undefined;
+ for (let i = 0; i < 6 && down; i++, down = down.child as Record | undefined) {
+ const name = nameOf(down as never);
+ if (name) descendants.push(name);
+ }
+ const props = Object.keys((found.fiber?.memoizedProps as object) ?? {}).slice(0, 8);
+ // For a Context.Provider the value's shape names the context far better
+ // than a minified component name does.
+ const rawValue = (found.fiber?.memoizedProps as { value?: unknown } | undefined)?.value;
+ const valueShape =
+ rawValue && typeof rawValue === "object"
+ ? `value{${Object.keys(rawValue).slice(0, 10).join(",")}}`
+ : `value:${typeof rawValue}`;
+ const key =
+ `${found.name} @depth${found.depth}` +
+ ` props[${props.join(",")}] ${valueShape}` +
+ ` > ${descendants.join(" > ")}` +
+ (chain.length ? ` < ${chain.join(" < ")}` : "");
+ state.roots.set(key, (state.roots.get(key) ?? 0) + 1);
+ }
+ },
+ __start: () => {
+ state.started = Date.now();
+ state.commits = 0;
+ state.byComponent.clear();
+ state.roots.clear();
+ },
+ };
+});
+
+const page = await context.newPage();
+await page.goto(`${baseUrl}/auth/login`, { waitUntil: "networkidle" });
+await page.getByLabel("Username").fill(username);
+await page.locator("#password").fill(password);
+await page.locator("css=button[type='submit']").click();
+await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, { timeout: 60_000 });
+
+await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
+await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 120_000 });
+await page
+ .waitForFunction(
+ () => {
+ const items = [...document.querySelectorAll('[data-type="item"]')];
+ return (
+ items.length > 0 &&
+ items.every((item) => item.querySelector("[data-homarr-widget-ready],[data-homarr-widget-error]"))
+ );
+ },
+ undefined,
+ { timeout: 120_000 },
+ )
+ .catch(() => console.log("warn: widgets did not all settle"));
+
+// Let the initial render storm finish before counting steady-state churn.
+await page.waitForTimeout(5_000);
+await page.evaluate(() => {
+ (globalThis as unknown as Record void }>).__REACT_DEVTOOLS_GLOBAL_HOOK__.__start();
+});
+
+console.log(`observing an idle board for ${observeMs / 1000}s (no interaction)...`);
+await page.waitForTimeout(observeMs);
+
+const stats = await page.evaluate(() => {
+ const state = (
+ globalThis as unknown as Record; started: number }>
+ ).__homarrRenderStats as unknown as {
+ commits: number;
+ byComponent: Map;
+ roots: Map;
+ started: number;
+ };
+ return {
+ commits: state.commits,
+ elapsedMs: Date.now() - state.started,
+ top: [...state.byComponent.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
+ roots: [...state.roots.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12),
+ };
+});
+
+const perMinute = (stats.commits / stats.elapsedMs) * 60_000;
+console.log(
+ `\ncommits while idle: ${stats.commits} over ${(stats.elapsedMs / 1000).toFixed(0)}s (${perMinute.toFixed(1)}/min)`,
+);
+console.log("\nupdate entered the tree at (commit roots):");
+for (const [name, count] of stats.roots) console.log(String(count).padStart(8), ` ${name}`);
+console.log("\nre-renders by component:");
+for (const [name, count] of stats.top) console.log(String(count).padStart(8), ` ${name}`);
+console.log(`\n${JSON.stringify({ commits: stats.commits, perMinute: Number(perMinute.toFixed(1)) })}`);
+await browser.close();