Files
homarr/scripts/benchmarks/measure-widget-churn.mts
T
Thomas Camlong 9f1a15b9bf 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.
2026-08-10 17:45:12 +02:00

96 lines
4.3 KiB
TypeScript

/**
* 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();