deer-flow/docs/superpowers/specs/2026-07-31-frontend-performance-remediation-design.md
DanielWalnut 459dd78707
perf(frontend): bound delivery, bundles, and long-running UI work (#4622)
* docs: design frontend performance remediation

* docs: plan frontend performance remediation

* test(frontend): add route asset performance budgets

* perf(nginx): compress textual responses safely

* perf(frontend): lazy load case study media

* perf(frontend): bound static demo file tracing

* perf(frontend): restore static locale boundaries

* perf(frontend): defer closed workspace panels

* perf(frontend): split editors and deduplicate highlighting

* perf(frontend): index incremental message derivation

* perf(frontend): stabilize paged history cache policy

* perf(frontend): bound streaming markdown renders

* perf(frontend): virtualize message history

* perf(frontend): bound and virtualize chat lists

* perf(frontend): suspend inactive decorative animation

* perf(browser): stream latest frames as binary

* perf(artifacts): stream bounded text previews

* docs: finalize performance runtime boundaries

* style(backend): apply test formatting

* fix(frontend): keep translation functions client-side

* perf(frontend): defer decorative animation bundles

* test(frontend): lock optimized route budgets

* fix: harden frontend performance boundaries

* test(frontend): update i18n provider fixture

* fix(frontend): preserve sidebar pagination position

* style(backend): format artifact range test
2026-08-01 22:19:59 +08:00

18 KiB

Frontend performance remediation — design

Background

A production-oriented audit identified seventeen performance risks across the default DeerFlow web deployment. The audit was originally performed against an older main, so this design starts from a source re-audit of origin/main@17461ee5 rather than treating the old findings as immutable.

Upstream has already improved three parts of the original report:

  • render-facing stream messages are coalesced to an 80 ms budget and the main history/live/optimistic merge is memoized;
  • processing groups build tool-result and browser-view indexes once instead of scanning the group for every tool call;
  • binary artifacts are served with FileResponse, including byte-range support.

Those changes are preserved. The remaining work is one comprehensive PR made of independently reviewable commits, with a measurement and regression gate after each slice.

Goals

  1. Fix every still-reproducible item from the seventeen-item audit rather than only the easiest P0 subset.
  2. Bound network bytes, client bundle bytes, retained DOM, repeated stream computation, animation work, browser-frame copies, artifact preview memory, and mock-route filesystem work.
  3. Preserve chat ordering, streaming semantics, history pagination, anchored scrolling, locale behavior, artifact downloads, Browser Live control, and the default Docker/local deployment topology.
  4. Add deterministic tests or executable measurements for every fix so later changes cannot silently restore the same cost.
  5. Deliver the work as one PR whose commits can be reviewed and reverted by subsystem.

Non-goals

  • Replacing LangGraph, Streamdown, Nextra, CodeMirror, or the existing query layer.
  • Changing model, sandbox, agent, or scheduler behavior.
  • Optimizing third-party hosted deployments that bypass DeerFlow's bundled nginx; the default nginx/Next deployment is the delivery contract here.
  • Claiming a performance win from source shape alone. Production build output and runtime measurements are required.

Current finding matrix

# Audit topic Current status on 17461ee5 Planned disposition
1 No explicit gzip/Brotli in bundled nginx Reproduced Add portable gzip for compressible static/document responses while excluding SSE and already-compressed media; test the generated nginx config and live headers.
2 Full-history work on every stream chunk Partially fixed upstream Keep the 80 ms render coalescer, then make grouping, usage, and human-input derivation reuse an immutable prefix and recompute only the active tail. Compare incremental results with the existing full derivation in tests.
3 Loaded message history is not virtualized Reproduced Add turn-level dynamic-height windowing with anchored prepend and overscan; keep the active streaming turn mounted.
4 No component-level code splitting Reproduced Lazy-load settings, inactive settings sections, Browser Live, artifact preview/editor, and other interaction-only heavy surfaces.
5 Docs/blog carry excessive shared dependencies Needs current production measurement; source risk remains Establish a fresh route asset baseline, remove workspace-only providers/styles from the root layout, and enforce route budgets.
6 Root locale cookie makes the whole site dynamic Reproduced Make the root layout static; move cookie-aware locale providers to auth/workspace and keep public locale resolution route-owned.
7 Streaming Markdown repeatedly parses growing text Reproduced Align reveal updates to the stream render budget, eliminate the per-animation-frame parse loop, and keep cheap streaming code rendering until the block settles.
8 Focus can refetch every loaded history page Reproduced Disable focus refetch for immutable paged history and explicitly invalidate/refetch on run lifecycle events.
9 Tool lookup and steps.indexOf produce quadratic work Partially fixed upstream Preserve the new lookup maps; replace remaining repeated steps.indexOf(...) calls with indexed conversion metadata.
10 Code blocks run and retain two Shiki renders Reproduced Lazy-load Shiki and produce one dual-theme CSS-variable rendering, with request/result caching and stale-effect protection.
11 Below-fold landing case-study images load eagerly Reproduced Replace CSS backgrounds with optimized lazy images, correct responsive sizes, and retain the card overlay/hover behavior.
12 Galaxy and Magic Bento do offscreen/unthrottled work Reproduced Pause on invisibility/document hide, honor reduced motion, and coalesce pointer work to one animation frame.
13 Browser Live uses JSON + base64 + React state per frame Reproduced Send JPEGs as binary WebSocket messages, use revocable object URLs, and present only the latest frame per paint. Keep JSON for control metadata.
14 Text artifact preview reads/renders the full file Partially fixed upstream Serve text through range-capable responses, request a bounded preview first, show truncation/load-full affordances, and avoid mounting CodeMirror for oversized previews.
15 Chat lists render every loaded row in two surfaces Reproduced Share normalized query data, virtualize the full chats page, and bound the sidebar to a recent/pinned window with explicit older-chat navigation.
16 Global styles/translations/background queries are too broad Reproduced Scope Markdown/KaTeX/Nextra styles by route, avoid shipping both locale payloads where possible, and mount queries only with their visible feature surface.
17 Mock API uses synchronous IO and dynamic project tracing Reproduced Use async cached reads and a deterministic demo-thread manifest so request handling and output tracing do not scan the project tree.

Design

1. Reproducible performance baseline and budgets

Add a repository script that builds the production frontend and reports, per representative route, the transitive first-load JavaScript and CSS from Next's build manifests. The representative routes are:

  • /
  • /login
  • /workspace/chats
  • /workspace/chats/<fixture-id>
  • /en/docs
  • /blog/posts

The script writes no tracked build artifact. It prints stable JSON plus a human summary and can compare against a checked-in budget file. Budgets are set only after the fresh origin/main baseline is captured; the PR must improve the audited routes and may not regress an unrelated route beyond a small explicit tolerance. A second smoke check starts the production stack and records Content-Encoding, Cache-Control, and transferred bytes for HTML, JS, CSS, JSON, and SSE samples.

Long-thread and large-artifact fixtures provide runtime gates:

  • a synthetic history with hundreds of heterogeneous turns;
  • a continuously growing Markdown answer with code, math, and citations;
  • a multi-megabyte UTF-8 artifact;
  • a burst of Browser Live JPEG frames.

Unit tests pin bounded derivation and protocol behavior; browser measurements record DOM node count, rendered turn count, and frame URL cleanup.

2. Delivery, static rendering, and route ownership

The bundled nginx enables gzip for HTML, JavaScript, CSS, JSON, XML, and SVG. It does not gzip text/event-stream, fonts, images, video, archives, or other already-compressed payloads. Proxy buffering remains disabled for streaming routes. A config test and a live header smoke test prevent an apparently valid directive from being placed in the wrong nginx context.

The top-level Next layout becomes request-invariant:

  • it owns only document structure, the theme provider, and truly global CSS;
  • it does not call cookies();
  • Streamdown and KaTeX styles move to the layouts that render rich content;
  • landing navigation uses the public default locale, docs derive locale from their explicit [lang] segment, and blog keeps its own locale selection;
  • auth and workspace layouts read the locale cookie and mount I18nProvider.

The client provider receives only the selected locale from its server layout and synchronizes document.documentElement.lang when the workspace/auth locale changes. Translation dictionaries include formatter functions and therefore cannot cross the React Server Component serialization boundary. The interactive auth/workspace provider deliberately owns both small dictionaries so switching language remains immediate, while public landing, docs, and blog routes resolve one route-owned locale without mounting that provider. This retains the existing language switch behavior without making public routes dynamically rendered or putting both dictionaries in every route bundle.

3. Bundle boundaries

Interaction-only code must not be reachable from the initial workspace chunk. The workspace root keeps only a small settings-store listener. The settings dialog is imported after it opens, and each settings page is imported only when selected. Browser Live, artifact code/preview tooling, and CodeMirror language packages are loaded only when their panels and modes are used.

Shiki is loaded through an async highlighter boundary instead of a static top-level import. One Shiki call emits light and dark token variables in one DOM tree; theme changes are CSS-only. A bounded cache keys on code, language, line number mode, and highlighter configuration. Effects ignore stale resolutions when code changes or the component unmounts.

The baseline script verifies that docs/blog no longer inherit workspace-only chunks and that a closed settings/artifact/browser surface contributes no interaction-only chunk to initial workspace loading.

4. Bounded chat rendering and derivation

Message virtualization happens at the existing ThreadMessageGroup boundary, not individual tool steps. A small headless @tanstack/react-virtual adapter owns dynamic measurements, overscan, and stable identity keys while the existing conversation component remains the scroll owner. The newest streaming group is always in the render window. Prepending an older history page preserves the visual anchor; bottom-follow behavior continues only when the user was already pinned to the bottom. Selection, edit/regenerate controls, human-input cards, artifact links, and run-duration anchors remain inside their current group. A dependency-size comparison is part of the bundle gate; if the adapter would break the workspace route budget, the same interface is implemented locally rather than accepting an initial-load regression.

Full derivation remains the reference algorithm. A new incremental adapter stores the settled group prefix and recomputes only from the earliest message whose identity/content/run metadata changed. Tests feed append, in-place stream mutation, tool result, reasoning, history prepend, checkpoint replacement, summarization bridge, edit/regenerate, and hidden human-input sequences through both algorithms and require identical groups.

Turn usage, run-duration anchors, workspace-change anchors, and human-input state use the same stable-prefix boundary or keyed indexes. The active tail may change every 80 ms, but historical turns are not rescanned. Remaining steps.indexOf(...) calls are removed by attaching the index while steps are created.

Paged history uses refetchOnWindowFocus: false and a nonzero stale window. Run finish, stop, regenerate, edit-and-regenerate, and explicit refresh remain authoritative invalidation points.

5. Streaming Markdown

The SDK/coalescer's 80 ms snapshot is the upper-frequency source of renderable content. The current smooth-reveal hook must not turn one snapshot into a new full Markdown parse on every animation frame. It will reveal at the same bounded cadence (or render the coalesced snapshot directly when the delta is small), while Streamdown's visual animation handles appearance without creating extra source strings.

Incomplete code blocks continue to use the cheap streaming <pre>/<code> components. Shiki, Mermaid, and other expensive settled rendering activates only after the relevant block/turn is stable. DOM tests cover cancellation, rapid target replacement, incomplete fences/lists, reduced motion, and the final exact content.

6. Landing visuals

Case-study cards use next/image with fill, responsive sizes, and lazy loading. Only genuinely above-the-fold media can be priority-loaded. Source images may be re-encoded if doing so materially lowers bytes without visible quality loss; generated files remain deterministic and are compared in the route transfer report.

Galaxy owns a visibility state derived from IntersectionObserver, document visibility, and prefers-reduced-motion. Its RAF exists only while rendering is allowed. Magic Bento listens for pointer movement only while its section is active and coalesces geometry reads/writes to one RAF. Both components cancel pending work and animations on cleanup. Tests use mocked RAF/observers to prove that offscreen, hidden, reduced-motion, and unmounted states perform no frames.

7. Browser Live binary frame path

Browser session capture returns JPEG bytes rather than base64 text. The updated client requests frame_format=binary in the WebSocket URL. The gateway keeps its bounded lossy frame queue and sends frame entries with WebSocket.send_bytes; URL, tab, rejection, and input messages remain JSON text. Connections without the capability retain the legacy base64 JSON frame for rolling-deployment compatibility. The new protocol is self-demultiplexing by WebSocket message type and does not add an extra binary header.

The client treats binary messages as blobs, keeps only the newest pending frame, and publishes at most one object URL per animation frame. Replaced and unmounted URLs are revoked. JSON parsing is performed only for text messages. The static artifact screenshot fallback and all input/control behavior stay unchanged. Backend tests cover capability negotiation, byte delivery, the legacy fallback, queue dropping, metadata text frames, auth, and disconnect cleanup; frontend tests cover coalescing and URL revocation.

8. Bounded artifact previews

Active content remains download-only and binary media remains range-streamed. Regular text artifacts move to a range-capable inline FileResponse path. The frontend initially asks for a fixed byte range and reads response metadata to distinguish complete from truncated content. It shows file size and a clear "Load full file" action when truncated; download always returns the original file.

The 1 MiB preview limit is owned by the frontend, while the shared HTTP Range contract lets both regular files and bounded archive members honor it. The preview handles an incomplete UTF-8 tail safely, and tests cover ASCII, multibyte boundary, empty, exact-limit, oversized, skill-archive, active, and binary files. Large text opens in the lightweight preview first; CodeMirror is not instantiated until content is within its safe budget or the user explicitly loads the full file.

9. Conversation lists, queries, and mock data

useInfiniteThreads remains the single cache. A shared selector deduplicates and sorts pages once so the sidebar and chats page do not repeat normalization. The full chats page uses fixed/dynamic row virtualization. The sidebar renders a bounded recent window plus all pinned entries, and routes users to the full page for older conversations instead of silently auto-loading/rendering an unbounded list.

Channel/provider, scheduler, and other feature queries mount only with their visible page/panel. Locale payloads are route-scoped: public routes own one selected locale, while the interactive auth/workspace boundary owns both for instant switching. Route-scoped CSS is verified in the build manifest rather than inferred from import location.

Mock route handlers use promise-based filesystem APIs and a cached, deterministically generated demo-thread manifest. The workspace redirect and thread search do not call readdirSync or dynamically construct project-wide paths at request time. A manifest consistency test fails if demo fixtures are added or removed without regeneration.

Error handling and compatibility

  • Performance fallbacks preserve content: a failed lazy import shows the existing surface error boundary; a failed bounded preview offers download; unsupported binary Browser Live frames fall back to the latest static screenshot.
  • No user-authored content is moved into dangerouslySetInnerHTML beyond the existing Shiki output boundary.
  • WebSocket auth, origin, and exact thread-owner checks remain before session acquisition.
  • Range requests do not weaken the active-content download policy or path traversal validation.
  • Reduced work must not hide persisted history, discard a Browser Live control event, or change the final streamed Markdown text.

Implementation and commit slices

The single PR is implemented in this order:

  1. Baseline/budget tooling and failing regression tests.
  2. Nginx compression, static root ownership, route CSS/locale split, landing image delivery.
  3. Lazy bundle boundaries and single-pass Shiki.
  4. Incremental chat derivation, history query policy, message/chat virtualization, and bounded Markdown cadence.
  5. Visibility-aware landing effects and binary Browser Live frames.
  6. Range-bounded text artifact previews and async/cached mock data.
  7. Documentation, production build comparison, full frontend/backend checks, and review fixes.

Each behavior change follows red-green-refactor. Commits remain independently testable, but the PR description reports the aggregate before/after result and maps every audit item to its evidence.

Verification and completion gate

The PR is not complete until all of the following are true:

  • every row in the seventeen-item matrix has a code change or current evidence proving that no change is required;
  • focused unit/DOM/backend tests pass and demonstrate their initial failure;
  • cd frontend && pnpm check, pnpm test, and pnpm build pass;
  • relevant Playwright suites pass for streaming, history prepend, settings, artifacts, and Browser Live;
  • backend artifact/browser tests, the blocking-IO gate for changed async code, Ruff lint, and format checks pass;
  • nginx configuration validation and live compression/SSE smoke checks pass;
  • production route asset/transfer budgets pass and the before/after report is included in the PR;
  • source review, automated review, PR review threads, and CI contain no unresolved actionable finding.

An absence of review comments is not sufficient by itself: the final audit must walk this matrix and attach authoritative evidence for each row.