Files
homarr/scripts/benchmarks/measure-board-paint.mts
T
Thomas Camlong 7550766f67 perf(media-releases): stop rendering 73 off-screen cards' images and layout
Per-widget attribution of the dashboard found one widget responsible for 44% of the
board's DOM: mediaReleases at 2,951 nodes and 8,387 fibers, including 1,935 Mantine
Box components, against a board total of 9,293 nodes.

The cause is that it maps over every release the integrations return, with no cap.
Measured on a real board: the tile is 539 px tall and its content is 7,206 px, so of
80 rendered cards only 7 are visible. The other 73 cost DOM, fibers, and - most
expensively - a poster and a CSS backdrop image each, all fetched and decoded for
pixels nobody can see. Decoded bitmaps are far larger in memory than their files.

Fixed with content-visibility: auto plus contain-intrinsic-size on each card, so the
browser skips layout, paint, raster and background-image fetch for off-screen cards,
and loading="lazy" on the poster. Nothing leaves the DOM, so scrolling and in-page
search are unchanged, and the intrinsic size keeps the scrollbar from jumping.

Measured over three repeats per side on identical restored data:

  image requests    135, 135, 135  ->  55, 55, 56   (-59%, deterministic)
  image bytes            12.27 MiB  ->  8.41 MiB    (-31%)
  layout objects  8346, 7815, 7815  ->  5117, 4696, 4636  (-41%)

Two things this does NOT show, stated because a single run suggested otherwise:

  - JS heap did not clearly improve. Across four runs it was 46-88 MiB before against
    74-86 MiB after; the before spread is 2x, so no claim either way. This is expected:
    decoded image memory lives outside JSHeapUsedSize, so the actual saving from 81
    fewer image fetches is invisible to that metric.
  - Task duration and time-to-settled overlap between arms and support no conclusion.

Adds the tools this needed: measure-widget-cost.mts (per-widget DOM nodes, fibers and
heaviest components), measure-board-paint.mts (image requests, bytes, style/layout/task
time, layout objects), measure-widget-overdraw.mjs (rendered versus visible rows).
2026-08-10 19:05:17 +02:00

80 lines
4.0 KiB
TypeScript

/**
* Measures what off-screen rendering actually costs the browser: image requests, bytes fetched,
* and time spent in style, layout and paint.
*
* DOM node counts and JS heap do not move when `content-visibility` is applied — nothing leaves the
* DOM — so measuring those says nothing about it. What changes is whether the browser fetches,
* decodes and rasterises content that is scrolled out of view, and decoded bitmaps are far larger
* in memory than the files they came from.
*
* STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
* node --experimental-strip-types scripts/benchmarks/measure-board-paint.mts
*/
import { chromium } from "@playwright/test";
const baseUrl = process.env.STRESS_BASE_URL;
if (!baseUrl) throw new Error("STRESS_BASE_URL is required");
const boardName = process.env.STRESS_BOARD ?? "default";
const settleMs = Number(process.env.PAINT_SETTLE_MS ?? 10_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(process.env.STRESS_USERNAME ?? "demo");
await page.locator("#password").fill(process.env.STRESS_PASSWORD ?? "demodemo");
await page.locator("css=button[type='submit']").click();
await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, { timeout: 60_000 });
// Count image traffic only for the board navigation, not the login page.
const images: { url: string; bytes: number }[] = [];
page.on("response", (response) => {
const type = response.request().resourceType();
if (type !== "image") return;
const length = Number(response.headers()["content-length"] ?? 0);
images.push({ url: response.url(), bytes: Number.isFinite(length) ? length : 0 });
});
const client = await context.newCDPSession(page);
await client.send("Performance.enable");
const startedAt = Date.now();
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.length > 0 && items.every((i) => i.querySelector("[data-homarr-widget-ready],[data-homarr-widget-error]"));
},
undefined,
{ timeout: 120_000 },
)
.catch(() => console.log("warn: widgets did not all settle"));
const settledMs = Date.now() - startedAt;
// Give lazy loading and raster a chance to do (or skip) their work.
await page.waitForTimeout(settleMs);
const metrics = await client.send("Performance.getMetrics");
const metric = (name: string) => metrics.metrics.find((entry) => entry.name === name)?.value ?? 0;
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
const mediaImages = images.filter((image) => /image|poster|backdrop|tmdb|thetvdb|fanart|_next\/image/i.test(image.url));
console.log(`\n=== board "${boardName}" ===`);
console.log(` all widgets settled ${settledMs} ms`);
console.log(` image requests ${images.length}`);
console.log(` of those, media artwork ${mediaImages.length}`);
console.log(` image bytes (declared) ${(totalBytes / 1048576).toFixed(2)} MiB`);
console.log(`\n time in style recalc ${(metric("RecalcStyleDuration") * 1000).toFixed(0)} ms`);
console.log(` time in layout ${(metric("LayoutDuration") * 1000).toFixed(0)} ms`);
console.log(` script duration ${(metric("ScriptDuration") * 1000).toFixed(0)} ms`);
console.log(` task duration ${(metric("TaskDuration") * 1000).toFixed(0)} ms`);
console.log(` JS heap used ${(metric("JSHeapUsedSize") / 1048576).toFixed(1)} MiB`);
console.log(` DOM nodes ${metric("Nodes")}`);
console.log(` layout objects ${metric("LayoutObjects")}`);
await browser.close();