← Back to devlog

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 facts — file sizes, sample counts, and base-call counts for each test AB1 file
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

Load, parse, and first-render timing medians (milliseconds) per fixture
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 latency medians (milliseconds) per fixture for each user action
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-355 does a visible-range max-Y scan, then two full peakPositions passes (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-369 rebuilds the derived display trace (apply edit model, maybe reverse-complement, rerun callMixedBases(), rebuild annotation features), 372-382 pushes that trace back through the renderer, and 385-398 immediately 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-148 starts with panel.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-210 linearly 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

Reliability — cross-feature interaction matrix

Reliability matrix — pass/fail outcomes for cross-feature interaction scenarios
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 from W→A, position 9 from M→A, and position 12 from T→A so the first 12 bases become SSSKKAWRARRA, 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 for RRTCA, then lower the mixed-base threshold to 0.10. The visible motif at the active locus changes to RRKKR, but the search counter remains at 7. Root cause is visible in src/components/TraceViewer.ts:286-299: applySearchQuery() calls findSubsequenceMatches(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, load 3100.ab1, switch back to the first tab. Result: the edited base is gone. Root cause is in src/components/TraceViewer.ts:638-649 and 706-714: the slot save path persists raw trace, strand, trim, search, mixed-base threshold/result, and viewport, but not editModel or its undo/redo stacks.

Consistency catalog

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 in TraceWorkspace so 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 for hitTest() 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.