High-Performance Serial Viewer โ Render 1 Million Base Pairs in Real Time in the Browser Using WebAssembly
Upon Completion of This Topic
You will be able to create your own viewer that smoothly renders large DNA sequences in the browser by combining the WebAssembly, DOM, and Big-O complexity concepts you learned in the textbook. You will gain an understanding of the principles behind practical tools like IGV.js through code.
This article is an educational, general example. Real-world sequence viewers are much more sophisticated, but this covers the core principles of performance optimization.
"My Browser Froze!" โ The Pitfalls of Naive Rendering
Let's say you want to display a 1 million base pair DNA sequence in a browser.
First attempt:
function renderSequence(sequence) {
const container = document.getElementById("viewer");
for (let i = 0; i < sequence.length; i++) {
const span = document.createElement("span");
span.textContent = sequence[i];
span.className = `base-${sequence[i]}`;
container.appendChild(span);
}
}
renderSequence(oneMillionBp);Running this code will cause the browser to freeze for over 5 seconds before it finally renders. And the page scrolling will be severely laggy.
The problems:
Problem 1: 1 million DOM nodes. Each span has a memory overhead of at least a few KB. In total, that's several GB.
Problem 2: DOM insertion is close to O(nยฒ). Each appendChild triggers a layout recalculation for all previous nodes.
Problem 3: String iteration is slow in JavaScript. Looping through 1 million characters is much slower than native code.
The real approach focuses on three axes:
- Render with Canvas instead of DOM (no 1 million nodes)
- Virtualization โ only render the sequence within the viewport (O(viewport) complexity)
- WebAssembly โ run performance-critical logic at native speed
From Black Box to Components
Component 1: Sequence Viewer - Big-O Perspective
Analyze the computational cost required for each frame of the sequence viewer.
Naive Approach: Scan the entire sequence in each frame. O(n).
Virtualization: Render only the portion of the sequence that is within the viewport. O(viewport).
Indexing: Enable O(1) lookup for specific positions.
For a 1 million bp sequence, with a viewport of, for example, 200 bp:
- O(n) = 1 million operations
- O(viewport) = 200 operations
- 5,000x faster
This difference is key to achieving 60fps real-time rendering.
Component 2: Canvas-Based Rendering
Use a single canvas instead of the DOM.
function initCanvas(container) {
const canvas = document.createElement("canvas");
canvas.width = container.clientWidth;
canvas.height = 100;
container.appendChild(canvas);
return canvas.getContext("2d");
}
function renderViewport(ctx, sequence, startBp, endBp, bpWidth) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const colors = { A: "#66c2a5", C: "#fc8d62", G: "#8da0cb", T: "#e78ac3" };
for (let i = startBp; i < endBp && i < sequence.length; i++) {
const x = (i - startBp) * bpWidth;
const base = sequence[i];
ctx.fillStyle = colors[base] || "#ccc";
ctx.fillRect(x, 20, bpWidth, 40);
if (bpWidth >= 8) {
ctx.fillStyle = "black";
ctx.font = "12px monospace";
ctx.fillText(base, x + 2, 45);
}
}
}The canvas holds the rendered portion as a visual representation. During a scroll event, simply update startBp and endBp and redraw.
Component 3: Offload Performance-Critical Logic to WebAssembly
Canvas rendering itself is sufficient in JavaScript. However, for performance-critical tasks such as GC content calculation, motif searching, and sorting, WebAssembly offers advantages.
Example of GC content calculation in Rust:
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn gc_content_window(sequence: &str, window_size: usize) -> Vec<f32> {
let bytes = sequence.as_bytes();
let n = bytes.len();
if n < window_size {
return vec![];
}
let mut result = Vec::with_capacity(n - window_size + 1);
let mut gc_count: usize = 0;
for i in 0..window_size {
if bytes[i] == b'G' || bytes[i] == b'C' {
gc_count += 1;
}
}
result.push(gc_count as f32 / window_size as f32);
for i in window_size..n {
if bytes[i] == b'G' || bytes[i] == b'C' {
gc_count += 1;
}
if bytes[i - window_size] == b'G' || bytes[i - window_size] == b'C' {
gc_count -= 1;
}
result.push(gc_count as f32 / window_size as f32);
}
result
}Build:
wasm-pack build --target webThis will generate the .wasm file and the JS bridge in the pkg/ folder.
Usage in the browser:
import init, { gc_content_window } from "./pkg/sequence_viewer.js";
async function main() {
await init();
const sequence = "ATGCGATCGATCG...".repeat(100000);
console.time("WASM GC");
const gcArray = gc_content_window(sequence, 100);
console.timeEnd("WASM GC");
console.log(`Computed GC for ${gcArray.length} windows`);
}For a 1 million bp sequence, calculating sliding window GC:
- Pure JavaScript: ~500ms
- WASM: ~15ms
- ~30x faster
Component 4: Scroll Events and Render Loop
Smooth scrolling = 60fps = within 16ms per frame.
class SequenceViewer {
constructor(container, sequence) {
this.ctx = initCanvas(container);
this.sequence = sequence;
this.startBp = 0;
this.bpWidth = 4;
this.viewportBp = Math.floor(this.ctx.canvas.width / this.bpWidth);
this.attachEvents(container);
this.render();
}
attachEvents(container) {
container.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = Math.sign(e.deltaY) * 10;
this.startBp = Math.max(0, Math.min(this.sequence.length - this.viewportBp, this.startBp + delta));
requestAnimationFrame(() => this.render());
});
}
render() {
const endBp = Math.min(this.sequence.length, this.startBp + this.viewportBp);
renderViewport(this.ctx, this.sequence, this.startBp, endBp, this.bpWidth);
}
}Key: Align with the browser's render cycle using requestAnimationFrame. This automatically achieves 60fps.
Fading โ Three Blanks for You to Fill
Blank 1: Multi-Track Renderer
Overlays multiple tracks, such as read alignments and gene annotations, on top of a sequence.
class MultiTrackViewer extends SequenceViewer {
constructor(container, sequence, tracks) {
super(container, sequence);
this.tracks = tracks;
this.ctx.canvas.height = 100 + tracks.length * 30;
}
render() {
super.render();
// TODO: Draw each track below the sequence.
// track = { name: string, features: [{start, end, color, label}] }
// Only draw features within the viewport range.
for (let i = 0; i < this.tracks.length; i++) {
// TODO: Convert the (start, end) of each feature to canvas coordinates and draw a rectangle.
}
}
}Hint: const xStart = (feature.start - this.startBp) * this.bpWidth; const xEnd = (feature.end - this.startBp) * this.bpWidth; if (xEnd < 0 || xStart > canvas.width) continue;.
Blank 2: WASM Motif Search
Implement the previously discussed trie in Rust and expose it as WASM.
#[wasm_bindgen]
pub fn find_motif(sequence: &str, motif: &str) -> Vec<usize> {
// TODO: Return all positions where the motif appears in the sequence.
// Hint: sequence.match_indices(motif).map(|(i,_)| i).collect()
vec![]
}Usage in the browser:
const positions = find_motif(sequence, "GAATTC"); // EcoRI restriction site
positions.forEach(pos => drawMarker(pos));Blank 3: Performance Benchmark
Compare the performance of a pure JavaScript implementation with a WASM implementation.
async function benchmark() {
const sequence = "ATCGATCG".repeat(125_000); // 1 million bp
// TODO 1: Implement a pure JavaScript GC calculation function (as a reference).
function gcContentJs(seq, window) {
// Your implementation
}
// TODO 2: Measure the time for both JS and WASM.
// console.time / console.timeEnd
// TODO 3: Verify that the results are the same (e.g., samples[0], samples[500000], samples[999900]).
// TODO 4: Display the results.
}Reflections โ Differences from a Production-Ready Genome Viewer
IGV.js: JavaScript port of the Integrative Genomics Viewer. Supports various standard formats such as BAM, VCF, and BED. Operates with only the client-side code, without a backend server.
Ensembl ยท UCSC Genome Browser: Representative web-based genome browsers. They have sophisticated backend infrastructure and cache various track data.
JBrowse 2: A modern architecture genome browser. Combines WebWorker, WASM, and React. Supports very large datasets.
Deep learning visualization: Recent tools like AlphaFold require 3D structure visualization. WebGL/WebGPU is needed.
Streaming processing: In production, the entire sequence is not loaded into the browser. Instead, a range request pattern is used to request only the necessary parts from the server. Web Sockets/HTTP2 push.
Extension Project
1. FASTA Loader: Load a FASTA file into the viewer when the user drags and drops it.
2. GFF/BED Parser: Parse gene annotation files and display them as tracks.
3. Search UI: Implement a text input field for searching motifs. Highlight the results on the minimap.
4. Export: Save the current viewport as a PNG/SVG image.
Component Implementation Guide
- [F] WebAssembly: Rust โ wasm-pack โ Browser loading. JS-WASM bridge.
- [F] DOM: Minimize DOM manipulation. Replace 1 million elements with a single canvas.
- [F] Big-O: O(n) โ O(viewport) virtualization. Concept of performance budget.
- [W] HTML/JS Fundamentals: Event handling,
requestAnimationFrame(complete script provided).
[F] = You implement it yourself / [W] = Provided as complete code.