Skip to content

perf(evalboard): cut page load time and show loading state while pages load - #152

Merged
bai-uipath merged 7 commits into
mainfrom
bai/evalboard-perf
Sep 3, 2026
Merged

perf(evalboard): cut page load time and show loading state while pages load#152
bai-uipath merged 7 commits into
mainfrom
bai/evalboard-perf

Conversation

@bai-uipath

@bai-uipath bai-uipath commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Loading the front page, filtering to a tag inside a run, and the download button were all slow. Reviewing the result turned up more on the same pages, so this also cuts the HTML those pages ship, gives every slow route a loading state, restores a filter segment that had gone missing, and corrects a model count in the run header.

Rough before and after

What Before After
Front page, cache warm 1.25s under 10ms, and warm now lasts a day on settled runs instead of 5 minutes
Front page, cold render ~205 run.json files, ~420 MB read ~294 MB of that no longer read at all
Ad-hoc section every candidate loaded, 165 runs newest page only, ~20 runs
Run page, per request run.json parsed twice, plus a serial 20-run scan parsed once, scan batched; under 10ms warm
Tag click inside a run full server re-render of a force-dynamic route no request at all
Mature-source scan up to 20 sequential reads 4 concurrent batches
Whole-run download ~10k blobs, ~400 MB, buffered in memory before the first byte removed
Per-task zip checksum 94ms per 50 MB, on the event loop 2ms
Run page HTML 7.6 MB raw, 222 KB gzipped 6.0 MB raw, 205 KB gzipped
/trends HTML 5.6 MB raw 4.5 MB raw
Feedback while a page loads none, the previous page just sits there skeleton immediately

Two honesty notes on that table. The warm figures are local; the "before" warm and cold figures are production App Insights, and the production cold path stays unverified until this deploys. And the HTML reduction is mostly a parse win rather than a transfer win, since gzip already collapses repeated class text.

What changed

Front page. A cold render read about 205 run.json files off the /home Azure Files mount. Four independent causes: the ad-hoc section loaded every candidate ever uploaded in order to show ten rows; per-run projections expired every 5 minutes however old the run was; the same run.json was read and parsed twice per request; and the mature-source scan walked earlier runs one at a time. All four are now bounded or shared.

Filtering inside a run. Every tag click re-ran the whole server component to return markup the browser then recomputed anyway, since nothing on that page reads the filter params server-side. Filters now commit through the native History API: the URL stays shareable, the back button still works, and there is no round-trip.

Download. Whole-run download is removed. A nightly run is about 10k blobs and 400 MB, and zipping it fetched every blob with no concurrency cap and buffered the whole archive in memory before sending a byte. Per-task download stays, and its checksum now comes from zlib rather than a per-byte loop.

Loading states. Every heavy route is force-dynamic, so a navigation has to run the server component before it can render anything, and there was no Suspense boundary anywhere in the app. The browser held the previous page for the whole wait with no sign the click had registered. There is now a route-level skeleton for the front page (covering trends, watchlist, path-to-ga, scribe and runs/latest by nesting), the run page, and the task page. The run page's own Suspense fallback={null} is replaced with the same body skeleton, since it had been leaving a blank hole under an already-rendered header. Blocks mirror the real breakpoints, so the layout does not move when content lands, and tests assert no fixed width can overflow a 320px viewport.

Payload on the run page and /trends. 55% of the markup on both pages was class attribute text, and a single 130-character chip string accounted for 1.4 MB on its own. Those combinations now live in the stylesheet, which the browser parses once and reuses across navigations. No visual change: every generated rule was diffed against the utilities it replaced in the built CSS. The same audit found every other page already under 610 KB, so those two were the only ones worth touching.

Harness filter. Segments were discovered from the last 12 runs, so delegate vanished between its weekly firings and read as a harness that had been removed. The known set now always gets a segment, and discovery still drives the chart series.

Model count in the run header. The header claimed "+1 more" on runs that used one model end to end, because a task that errors before the model resolves records the qualified id while completed tasks record the bare one. The count now groups on the normalized id, the same key pricing already resolves on. Display still shows what the run actually recorded.

On caching

A run older than a day now holds its projection for a day instead of 5 minutes. That reads like a staleness risk and is not, because the on-disk blob cache underneath never re-fetches a file once it exists: a sidecar edited in storage was already stale until someone pressed Refresh, whatever TTL sat above it. Refresh is still the only thing that clears it, and it now evicts the projection alongside the disk copy, which is the part that actually needed fixing. Deleting or re-uploading a run behaves exactly as before. The one assumption worth recording is single-instance, since tag eviction is per process; the site runs one worker and has no autoscale rule.

Also done outside the repo

alwaysOn was false on the App Service and is now true (flow-evalboard / rg-coder-eval-tests). It is free on the current P2v3 plan and was the last cause of a cold front page that no code change could reach, since the worker idled out and the first visitor after a quiet stretch paid the whole boot. No repo change was needed and none is needed to keep it: nothing in the deploy path writes siteConfig and neither repo carries IaC. Verified healthy afterwards, 30 requests in the next five-minute bin, all 200.

Two notes for anyone checking by hand. An anonymous curl against the public hostname answers 401 from Easy Auth, which is expected and not a failed boot. az webapp auth show reports nulls because the site is on classic V1 while the CLI extension reads V2.

Verification

tsc --noEmit, 637 vitest tests, and next build all pass. New coverage for the id-date extraction, the batched mature-source walk, the refresh tag eviction, the zip checksum, the harness segments, the model tally, and the skeletons (accessible name, reduced motion, no overflowing width, both grid breakpoints present).

bai-uipath and others added 5 commits September 2, 2026 16:43
The front page took 5-8s cold and ~1.25s warm, and the split was exactly
the 5-minute per-run cache TTL. Four causes, all redundant IO against the
`/home` Azure Files mount:

- The ad-hoc section loaded EVERY non-date-shaped candidate before
  sorting — 165 runs / ~294 MB of run.json — to render ten rows. It now
  orders candidates by the date in the id and loads only the newest
  page's worth. run.json `start_time` is still the authoritative sort.
- Per-run projections were cached for 5 minutes regardless of age, so
  every visit after a short pause re-read the whole window. A run.json
  is written once at the end of a run, so runs older than 24h now cache
  for a day. /api/refresh evicts by tag, keeping the escape hatch.
- readRunSummary and readRunTasks each read and parsed the same
  multi-MB run.json; the run page did both on every request. Memoized
  per request with react/cache.
- findMatureSourceRuns walked up to 20 earlier run.jsons serially, and
  only short-circuits once every mature task resolves, which at ~50%
  maturity usually never happens. Now reads in concurrent batches,
  consumed in index order so "most recent execution wins" is unchanged.

Filtering within a run also round-tripped the server for nothing:
the tag/search params on that page are read entirely client-side, but
committing them through router.replace re-ran the whole force-dynamic
page. Switched to the native History API, which Next syncs into
useSearchParams without a fetch.

Whole-run download is removed — button, route branch, collectRunFiles
and ensureRunDir. A nightly run is ~10k blobs / ~400 MB, and zipping it
meant an uncapped blob fan-out, a stat-per-file walk over Azure Files,
and the entire archive buffered in memory before the first byte was
sent. Per-task download stays and is unchanged; its CRC-32 now comes
from zlib instead of a per-byte JS loop (~47x, measured).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness segments were purely data-driven off listRecentHarnesses,
which discovers from the last 12 usable pipeline runs. Delegate runs on a
weekly cron, so between firings its last run slides past that window and
the segment disappears, which reads as "delegate was removed" rather than
"delegate hasn't run lately".

The known set now always gets a segment. Selecting one with no runs in
scope shows an empty result, which is honest and one click from
recoverable; a control that silently loses an option is neither.

Discovery still drives the chart series, so no empty lines appear
alongside the restored segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sheet

The run page shipped 7.6 MB of HTML for a 1,296-task run and /trends
shipped 5.6 MB. In both, 55% of the markup was `class` attribute text:
56k attributes carrying only 120 distinct values on the run page, 40k
carrying 78 on trends. One 130-char chip string accounted for 1.4 MB on
its own, repeated 10,918 times.

The hot combinations now live in globals.css as component classes, so the
browser parses them once and caches them across navigations instead of
re-reading them on every row. Chips carry the largest share; the run
grid's stat key, the trends numeric cell, and the trends sparkline bar
are the other three with five-figure render counts.

Chip hover moves from stripping `hover:` utilities out of the composed
string at runtime to a `chip-act` marker the interactive branch emits,
which the compound selectors key on. The non-interactive span still
shows no hover affordance.

Run page 7.64 MB -> 6.02 MB (-21%), trends 5.60 MB -> 4.49 MB (-20%).
No visual change: every generated rule was checked against the utilities
it replaced in the built stylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run header claimed "+1 more" on runs that used a single model end to
end. tallyModels voted on the raw model_used string, and that string
varies by code path for one and the same model: a row that errors before
the model resolves keeps the qualified id it was configured with
("eu.anthropic.claude-sonnet-5") while every completed row records the
bare one ("claude-sonnet-5"). One errored task was enough to make the
header report a mixed-model run.

The vote now groups on the normalized id, the same key pricing already
resolves on, so normalizeModel is exported rather than duplicated. Only
the "how many models" question moves to normalized keys: display stays
the most common raw string inside the winning group, so the chip still
shows what the run recorded instead of a value the header derived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment-only. Cuts the narration of what the code used to do and the
restatements of what it now does, keeping the reasons that aren't
recoverable from reading it: why the per-run cache is keyed per run
rather than per source, why the ad-hoc load key and the sort key differ,
why hover moved to a marker class, and why the model vote normalizes.

Net 57 lines. No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bai-uipath
bai-uipath marked this pull request as ready for review September 3, 2026 20:23
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Claude finished @bai-uipath's task in 1m 39s —— View job


Code Review in Progress

Todo List:

  • Read review guidelines from .github/code_review.md
  • Read project conventions from CLAUDE.md
  • Get full PR diff
  • Review changed files with full context
  • Perform cross-file consistency checks
  • Complete design-level scrutiny
  • Post formatted review

Starting review now...

Every heavy route is force-dynamic, so a navigation runs the server
component before anything can render. With no Suspense boundary the
browser sat on the PREVIOUS page for all of it: 1.4-1.6s on the front
page, 1.7-2.2s on a 1,296-task run, with no sign the click registered.

Adds a route-level loading.tsx for the front page (which by nesting also
covers trends, watchlist, path-to-ga, scribe and runs/latest), the run
page, and the task page, over shared primitives in _components/skeleton.

Also replaces the run page's `Suspense fallback={null}` with the same
body skeleton. That boundary left a blank hole under an already-rendered
header while the review index and mature-source scan finished, which
reads as a broken page rather than a loading one.

Blocks mirror the real breakpoints (two stat-tile columns on a phone and
five from md; the task table at md and up, cards below it) so the layout
does not move when content lands. Widths are fractional or capped, and
tests assert no fixed width can overflow a 320px viewport. The pulse
sits on the wrapper, not each block, and drops under
prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bai-uipath bai-uipath changed the title perf(evalboard): stop re-reading the whole run store on every render perf(evalboard): cut page load time and show loading state while pages load Sep 3, 2026

@uipreliga uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢

@bai-uipath
bai-uipath merged commit 5e7d2b6 into main Sep 3, 2026
15 checks passed
@bai-uipath
bai-uipath deleted the bai/evalboard-perf branch September 3, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants