2026-07-04 — v17 performance + reliability + consistency audit
This PR is intentionally docs-only. No viewer logic changed. The job here was to stop guessing, run the built app like a user would, and leave behind one hardening brief that the next implementation wave can execute against while the UI/UX design research runs in parallel.
Audit setup: production dist/ bundle served under the GitHub Pages
/sanger-viewer/ base path, Playwright Chromium 149, Node
v22.23.0, Linux Azure runner on a 4-vCPU Xeon 8370C. Every timing below is
the median of 3 runs. I used 3100.ab1 as the representative normal trace
because 310.ab1 collapses the trim path into an all-trimmed edge case at
every tested threshold; 310.ab1 still appears below in the reliability pass
where that edge case matters.
Fixture facts
| Fixture | Bytes | Initial viewport samples | Base calls | Why it was used |
|---|---|---|---|---|
3100.ab1 |
209,224 | 10,303 | 795 | Representative normal trace for interaction timing |
3730.ab1 |
299,987 | 16,302 | 1,165 | Current large stress fixture already covered by perf smoke |
Performance — real numbers from the built app
Load / parse / first-render
| Fixture | Worker parse round-trip | First non-blank canvas | Status → Loaded |
|---|---|---|---|
3100.ab1 |
12.4 ms | 81.4 ms | 85.4 ms |
3730.ab1 |
13.8 ms | 90.2 ms | 94.0 ms |
Read plainly: parsing is not the problem anymore. The large trace is only ~1.4 ms slower
than the normal trace in worker round-trip time. Most of the load budget is now the
post-parse pipeline: buildDisplayTrace(), trim/search/annotation setup, and
the first canvas + sequence-panel paint.
Interaction latency
| Interaction | 3100.ab1 |
3730.ab1 |
|---|---|---|
| Zoom + button | 34.2 ms | 40.2 ms |
| Pan → button | 55.9 ms | 56.3 ms |
| Wheel zoom / scroll | 48.9 ms | 54.4 ms |
| Trim slider | 20.7 ms | 21.5 ms |
| Find | 13.2 ms | 13.2 ms |
| Mixed-base threshold | 13.1 ms | 14.4 ms |
| Reverse-complement toggle | 54.8 ms | 63.4 ms |
| Edit one visible base | 70.9 ms | 80.9 ms |
The viewer feels decent because everything stays well under 100 ms, but the frame-budget story is still obvious: search and mixed-base recompute already fit inside a 16 ms target, trim is close, while pan / wheel / reverse-complement / edit are still in “one-to-two extra frames” territory.
Named bottlenecks
ChromatogramCanvas.draw()is still the hot path for pan/zoom/scroll-
src/render/ChromatogramCanvas.ts:268-355does a visible-range max-Y scan, then two fullpeakPositionspasses (quality glow bands, then base labels), plus four channel decimation/draw passes every frame. That lines up exactly with the 34-56 ms zoom/pan/wheel numbers. The viewer no longer redraws raw samples point-for-point, but it still rebuilds too much per frame. buildDisplayTrace()makes revcomp and edit effectively “full rebuild” actions-
src/components/TraceViewer.ts:315-369rebuilds the derived display trace (apply edit model, maybe reverse-complement, reruncallMixedBases(), rebuild annotation features),372-382pushes that trace back through the renderer, and385-398immediately retrims and rerenders the sequence/quality panels. That full chain runs on every strand toggle and edit, which is why reverse-complement lands at 54.8/63.4 ms and single-base edit lands at 70.9/80.9 ms. renderSequence()still throws away and rebuilds DOM-
src/components/SequencePanel.ts:37-148starts withpanel.innerHTML = ''and recreates up to 240 spans on every search, trim, hover, and post-edit refresh. The trim slider’s 20-21 ms median is acceptable today, but this full-DOM rebuild is directly coupled to the slower edit/revcomp path. hitTest()is an O(n) pointer scan waiting to become the next problem-
src/render/ChromatogramCanvas.ts:178-210linearly scans all called peaks to find the nearest visible base. The current fixtures are still small enough that this is not exploding, but any future “true large” trace or continuous hover-heavy workflow will pay for it.
Concrete budgets for the hardening wave
- Paint budget: keep first non-blank render under 120 ms on the current large fixture.
- Frame budget: pan / wheel / button zoom under 16 ms median; under 33 ms worst accepted budget.
- Derived-state budget: reverse-complement under 33 ms median; single-base edit under 50 ms median.
- No unnecessary rebuild rule: pan and wheel must not rebuild sequence/search/annotation state; edit should not force a full annotation rebuild when only one base changes.
Reliability — cross-feature interaction matrix
| Scenario | Result | What happened |
|---|---|---|
| Edit + revcomp + mixed-base + trim + export FASTA | Pass | Stayed interactive and exported a non-empty FASTA. No crash or blank state. |
| Undo / redo after trim-threshold change | Pass | Edited span disappeared on Undo and reappeared on Redo as expected. |
| Edit + search | Fail | After changing 1-indexed visible position 6 from W→A, position 9 from M→A, and position 12 from T→A to create the unique 12-mer SSSKKAWRARRA, FASTA export contained the edited motif but search returned 0 matches. |
| Active search + mixed-base recompute | Fail | With an active search for RRTCA, lowering the mixed-base threshold changed the visible local motif to RRKKR but the search-match count stayed at 7. |
| Multi-trace switching with edits | Fail | Switching from edited 310.ab1 to 3100.ab1 and back dropped the edit entirely; Undo history was gone too. |
Repros for the real gaps
- Gap R1 — Search runs against raw sequence, not displayed sequence
-
Repro A: load
3100.ab1, change 1-indexed visible position 6 fromW→A, position 9 fromM→A, and position 12 fromT→Aso the first 12 bases becomeSSSKKAWRARRA, then search for that exact 12-mer. Result: the sequence panel and FASTA export both show the edited motif, but search returns 0 matches. Repro B: search forRRTCA, then lower the mixed-base threshold to0.10. The visible motif at the active locus changes toRRKKR, but the search counter remains at 7. Root cause is visible insrc/components/TraceViewer.ts:286-299:applySearchQuery()callsfindSubsequenceMatches(rawTrace.sequence, normalizedQuery)instead of the currently displayed trace sequence. - Gap R2 — Workspace saves trim/search/viewport state, but not edits
-
Repro: load
310.ab1, edit base 1, load3100.ab1, switch back to the first tab. Result: the edited base is gone. Root cause is insrc/components/TraceViewer.ts:638-649and706-714: the slot save path persists raw trace, strand, trim, search, mixed-base threshold/result, and viewport, but noteditModelor its undo/redo stacks.
Consistency catalog
- Behavior: search semantics do not match what the user is looking at. The sequence panel, FASTA/FASTQ/QUAL exports, and mixed-base badges reflect the displayed trace; search still follows the raw forward sequence.
- Terminology: the toolbar mixes shorthand (
Q-trim), directional notation (5′→3′), and explicit export verbs (Export FASTA) without a single naming style. Strand state lives mostly in tooltip/title text, not the visible label. - Control placement: view controls, five export actions, edit recovery, search, trim, and mixed-base calling all live in one flat toolbar. Export buttons sit between strand navigation and edit/search actions instead of being grouped as output actions.
- Keyboard discoverability: Undo/Redo advertise shortcuts, but search Enter/Shift+Enter/Escape, focus-to-open base inspection, and double-click-to-edit are not surfaced anywhere in the UI.
- Theming and output scope: on-screen rendering is theme-aware, PNG/SVG are viewport snapshots, and FASTA/FASTQ/QUAL are whole-sequence exports with stateful suffixes like
-revcompand-trimmed. All three behaviors are reasonable individually; together they are not explained consistently. - Filename formatting: image exports always use
-viewnaming, while sequence exports encode strand/trim state but not viewport. The inconsistency is small, but it will matter once users start exporting heavily.
Prioritized hardening plan
- P0 — Make search truthful and workspace edits durable
-
Fix 1: compute search matches from the displayed trace sequence, not
rawTrace.sequence, and invalidate/recompute on edit, mixed-base threshold, reverse-complement, and slot switch. Fix 2: persist edit entries plus undo/redo stacks inTraceWorkspaceso tab switches round-trip edited state. Suggested regression tests: (a) create a unique edited 12-mer and assert search finds it, (b) hold an active search while mixed-base threshold changes and assert match counts update, (c) switch away from an edited tab and back and assert both the edited span and Undo button state survive. - P1 — Stop doing full rebuild work for frame-bound interactions
-
Split
buildDisplayTrace()so edit/revcomp do not always rebuild annotations, trim, and search from scratch; cache mixed-base results by threshold + strand; stop clearing/rebuilding the whole sequence panel on every refresh; and binary-search visible peaks forhitTest()instead of linearly scanning every called base. Suggested regression tests: Playwright perf harness with median budgets for zoom/pan/wheel/revcomp/edit, plus a DOM-mutation assertion that hover/search/trim updates patch existing spans instead of replacing the whole sequence panel tree. - P2 — Normalize UX language and export semantics
- Group toolbar actions by intent, surface hidden keyboard paths inline, and decide one consistent export-story vocabulary: either “viewport export” vs “sequence export” is made explicit in labels/help text, or filenames/metadata are normalized so users can predict what state is encoded where. Suggested regression tests: accessibility assertions on grouped controls, visible/help-text copy checks, and filename/content checks for all five export paths.
Bottom line
The good news: parsing is already cheap, first paint is comfortably sub-100 ms on the current largest fixture, and no measured interaction blew past 100 ms. The less-good news: the viewer is still paying 50-80 ms for actions that users perceive as frame-bound, and the biggest correctness gaps are exactly where features overlap — search vs displayed sequence, and workspace tabs vs edits. That makes the next wave straightforward: fix truthfulness first, then shave rebuild cost, then polish copy and grouping.