docs(findings): the App Router churn claim was wrong; idle churn is Beszel

Corrects the largest wrong claim in this work. Earlier rounds reported that ~80% of
idle React commits entered at Next's internal HeadManagerContext and that "the App
Router root re-renders about once a second", and named it the highest-value next step.

Both are false. Measuring the same idle churn against boards of different sizes:

  board      items   idle commits/min
  hey            0                0.0
  footbol        3                0.0
  default       28               76.0

A 3-item board commits zero times per minute. If the router re-rendered on a timer,
every board would churn regardless of its contents.

The original attribution was a tooling artefact. measure-rerenders.mts decides a fiber
re-rendered from fiber.actualDuration, which only a React *profiling* build populates.
On a production build every fiber fails that test and both attribution tables come back
empty - and an empty table reads as "nothing re-rendered" rather than "this build cannot
tell you". The harness now says which case it is, and waits for the board container to be
attached rather than visible so a zero-item board can be measured at all - that control
is what disproved the claim.

Adds measure-widget-churn.mts, which attributes churn on a production build via DOM
mutations keyed to the nearest [data-type="item"] ancestor. On the 28-item board, idle
for 45s:

  470  beszelSystemStats      64%
   77  beszelSystemTable      11%
   52  beszelSystemGrid        7%
   22  dockerContainers        3%
   93  (outside any widget)   13%

Three Beszel widgets are 82% of all idle churn, one of them 64% on its own at 10.4
mutations per second.

Also logs what Next.js itself costs, since it was assumed to be the problem: its loaded
source is a flat 3.47 MiB (364 framework files + 32 vendored) and does not grow at all
between boot and a fully loaded multi-tab session, while Homarr's own chunks grow from
3.73 to 15.44 MiB. Next is 16.1 MiB on disk; the largest things it vendors are its
next-server runtime (1.60), @edge-runtime (0.85), edge-runtime (0.51) and compression
(0.30). The framework is not where this application's memory goes.
This commit is contained in:
Thomas Camlong committed 2026-08-10 17:45:12 +02:00
1 parent ab5b8dad43
commit 9f1a15b9bf
3 files changed
+180 -23

No files matched your search

+35 -10
View File
@@ -22,7 +22,19 @@ 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<string, number>(), roots: new Map<string, number>(), started: 0 };
const state = {
commits: 0,
byComponent: new Map<string, number>(),
roots: new Map<string, number>(),
started: 0,
/**
* Whether any fiber carried `actualDuration`. React only populates it in a profiling
* build, so on a production build every fiber fails the re-render test and the
* attribution comes back empty — which reads as "nothing re-rendered" rather than
* "this build cannot tell you". Tracked so the report can say which it is.
*/
sawTimings: false,
};
(globalThis as unknown as Record<string, unknown>).__homarrRenderStats = state;
const nameOf = (fiber: { type?: unknown; elementType?: unknown }) => {
@@ -60,6 +72,7 @@ await context.addInitScript(() => {
if (!fiber || seen.has(fiber) || depth > 2000) return;
seen.add(fiber);
const alternate = fiber.alternate as Record<string, unknown> | null;
if (fiber.actualDuration !== undefined) state.sawTimings = true;
// 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);
@@ -122,15 +135,15 @@ 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 });
// Attached, not visible: a board with zero items renders this container hidden, and the
// empty-board case is exactly the control needed to tell shell churn from widget churn.
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "attached", 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]"))
);
// An empty board legitimately has zero items; only require settling if any exist.
return items.every((item) => item.querySelector("[data-homarr-widget-ready],[data-homarr-widget-error]"));
},
undefined,
{ timeout: 120_000 },
@@ -154,9 +167,11 @@ const stats = await page.evaluate(() => {
byComponent: Map<string, number>;
roots: Map<string, number>;
started: number;
sawTimings: boolean;
};
return {
commits: state.commits,
sawTimings: (state as unknown as { sawTimings: boolean }).sawTimings,
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),
@@ -167,9 +182,19 @@ 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}`);
if (!stats.sawTimings) {
// Without this the empty tables below read as "nothing re-rendered", which is the
// opposite of the truth when the commit count is high.
console.log(
`\nNO PER-COMPONENT ATTRIBUTION: this is a production React build, which does not populate` +
`\nfiber.actualDuration. The commit count above is still valid. For attribution, build with` +
`\n docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance`,
);
} else {
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();
@@ -0,0 +1,95 @@
/**
* Attributes idle re-render churn to individual widgets, on a production build.
*
* `measure-rerenders.mts` counts React commits but cannot say which component caused them:
* attribution needs `fiber.actualDuration`, which only a React profiling build populates.
* DOM mutations need no such thing. Every re-render that changes anything observable shows up
* as a mutation, and the mutated node's nearest `[data-type="item"]` ancestor names the widget.
*
* This is what established that idle churn is widget-driven rather than app-shell driven: a
* board with 3 items commits 0 times per minute while a 28-item board commits 76.
*
* STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
* node --experimental-strip-types scripts/benchmarks/measure-widget-churn.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.CHURN_OBSERVE_MS ?? 45_000);
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
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: "attached", timeout: 120_000 });
await page
.waitForFunction(
() => {
const items = [...document.querySelectorAll('[data-type="item"]')];
return 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);
const result = await page.evaluate(async (durationMs) => {
/** Names a widget from whatever the item element carries, falling back to its classes. */
const describe = (item: Element | null) => {
if (!item) return "(outside any widget)";
const attributes = [...item.attributes]
.filter((attribute) => /kind|widget|type|id/i.test(attribute.name) && attribute.value.length < 60)
.map((attribute) => `${attribute.name}=${attribute.value}`);
const marker = item.querySelector("[class*='-widget'],[class*='widget-']");
const className = marker?.className;
const hint = typeof className === "string" ? className.split(/\s+/).find((part) => part.includes("widget")) : "";
return `${hint ?? ""} ${attributes.join(" ")}`.trim() || "(unidentified item)";
};
const counts = new Map<string, number>();
let total = 0;
const observer = new MutationObserver((records) => {
for (const record of records) {
total++;
const node = record.target.nodeType === 1 ? (record.target as Element) : record.target.parentElement;
const key = describe(node?.closest('[data-type="item"]') ?? null);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
await new Promise((resolve) => setTimeout(resolve, durationMs));
observer.disconnect();
return {
total,
itemCount: document.querySelectorAll('[data-type="item"]').length,
byWidget: [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
};
}, observeMs);
console.log(`\nboard "${boardName}": ${result.itemCount} items, observed idle for ${observeMs / 1000}s`);
console.log(`DOM mutations while idle: ${result.total} (${((result.total / observeMs) * 60_000).toFixed(0)}/min)\n`);
console.log(`${"mutations".padStart(10)} widget`);
for (const [name, count] of result.byWidget) console.log(`${String(count).padStart(10)} ${name.slice(0, 100)}`);
await browser.close();