Skip to main content

2. Your Canvas Editor Runs at 20 FPS. Diagnose and Fix It.

Interview framing​

A Canvas editor running at 20 FPS has roughly:

1000ms / 20 FPS = 50ms per frame

For a smooth 60 FPS experience, the browser has roughly:

1000ms / 60 FPS ≈ 16.7ms per frame

So the editor is spending about 3× the available frame budget.

As a Staff Engineer, I would not immediately start micro-optimizing drawing code.

I would first answer:

  1. Where is the 50ms going?
  2. Is the bottleneck CPU, GPU, layout, React, memory/GC, or data processing?
  3. Does cost scale with total scene size or only visible objects?
  4. Is the problem continuous rendering or interaction-specific?
  5. Can we reduce work rather than merely make the same work faster?

1. Start With the Frame Budget

A frame may include:

Input
↓
JavaScript
↓
Style calculation
↓
Layout
↓
Canvas draw calls
↓
Raster / GPU compositing
↓
Display

At 60 FPS:

┌──────────────────────── 16.7ms ────────────────────────┐
│ JS │ layout │ draw │ raster │ composite │ browser work │
└─────────────────────────────────────────────────────────┘

At 20 FPS:

┌────────────────────────── ~50ms ──────────────────────────┐
│ Something is consuming ~30ms+ too much │
└────────────────────────────────────────────────────────────┘

My first goal is to identify which stage dominates.


2. First Diagnostic: Chrome Performance Panel

Record a trace while reproducing a specific slow interaction:

1. Open Chrome DevTools
2. Performance
3. Enable screenshots + memory if useful
4. Record
5. Pan / zoom / drag / resize for ~5 seconds
6. Stop
7. Inspect long frames

I look for:

SymptomLikely Cause
Long yellow blocksExpensive JavaScript
Purple layout/styleForced synchronous layout / DOM work
Heavy PaintToo much rasterization
Long GPU tasksLarge textures / compositing / fill rate
Sawtooth memoryAllocation pressure / garbage collection
React commits every frameReact state driving hot interaction loop
getBoundingClientRect interleaved with writesLayout thrashing
Thousands of repeated draw callsNo culling / batching
Full-canvas clear + redrawMissing dirty-region strategy

3. Measure the Actual Frame Loop

I want instrumentation around the hot path.

let last = performance.now();

function frame(now: number) {
const frameDuration = now - last;
last = now;

if (frameDuration > 16.7) {
console.log("slow frame", frameDuration);
}

renderScene();

requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

For more detail:

function measure<T>(name: string, fn: () => T): T {
const start = performance.now();

try {
return fn();
} finally {
const duration = performance.now() - start;

if (duration > 2) {
console.log(`${name}: ${duration.toFixed(2)}ms`);
}
}
}

function renderScene() {
measure("visibility", computeVisibleObjects);
measure("geometry", updateGeometry);
measure("canvas", drawCanvas);
measure("overlay", updateDOMOverlay);
}

The point is to decompose:

50ms/frame

Possible example:

10ms → scene traversal
18ms → path generation
12ms → canvas drawing
6ms → layout
4ms → GC / misc

Now optimization becomes targeted.


4. Check Whether We Render Everything

The first architectural question:

Are we rendering 100,000 objects because 100,000 exist, or because 2,000 are visible?

A Canvas editor should usually render based on the viewport.

Bad:

for (const object of allObjects) {
draw(object);
}

Better:

const visibleObjects = spatialIndex.query(viewport);

for (const object of visibleObjects) {
draw(object);
}

Architecture:

Scene: 100K objects
│
▼
Spatial Index
R-tree / quadtree / grid
│
▼
Viewport query
│
▼
~500 visible objects
│
▼
Canvas renderer

This often gives the largest improvement because complexity changes from approximately:

O(total scene size)

toward:

O(log N + visible objects)

5. Use Spatial Indexing

For large editors:

type Rect = {
x: number;
y: number;
width: number;
height: number;
};

interface SceneObject {
id: string;
bounds: Rect;
draw(ctx: CanvasRenderingContext2D): void;
}

Conceptually:

const visible = spatialIndex.search({
minX: viewport.left,
minY: viewport.top,
maxX: viewport.right,
maxY: viewport.bottom,
});

for (const item of visible) {
item.draw(ctx);
}

Possible structures:

StructureGood For
Uniform gridSimilar object sizes
QuadtreeSpatially distributed 2D objects
R-treeArbitrary rectangular bounds
BVHComplex geometry / graphics

I would choose based on scene characteristics rather than assuming one universal answer.


6. Stop Redrawing When Nothing Changed

A common Canvas performance bug is:

function loop() {
redrawEntireCanvas();
requestAnimationFrame(loop);
}

loop();

This produces 60 redraw attempts per second even when the user is idle.

Instead:

let dirty = true;

function invalidate() {
if (dirty) return;

dirty = true;
requestAnimationFrame(render);
}

function render() {
dirty = false;

drawScene();
}

Events trigger invalidation:

pointerMove → invalidate()
zoom → invalidate()
objectEdit → invalidate()
selection → invalidate()

Idle state:

0 canvas redraws

instead of:

60 redraws/sec

7. Dirty Rectangle Rendering

If only one object changes, avoid repainting the entire canvas.

Example:

Canvas
┌────────────────────────────────────┐
│ │
│ unchanged │
│ │
│ ┌───────────────┐ │
│ │ changed area │ │
│ └───────────────┘ │
│ │
└────────────────────────────────────┘

Concept:

function redrawDirtyRegion(region: Rect) {
ctx.clearRect(
region.x,
region.y,
region.width,
region.height
);

const objects = spatialIndex.query(region);

for (const object of objects) {
draw(object);
}
}

Caveat:

Dirty rectangles become more complex when objects:

  • overlap
  • have shadows
  • have filters
  • use blending
  • extend beyond their logical bounds

So I would measure whether the complexity is worth it.


8. Separate Static and Dynamic Layers

During dragging, most of the scene is unchanged.

Use multiple Canvas layers:

┌────────────────────────────────────┐
│ HTML controls / accessibility │
├────────────────────────────────────┤
│ Canvas: selection / drag overlay │
├────────────────────────────────────┤
│ Canvas: static scene │
├────────────────────────────────────┤
│ Background grid │
└────────────────────────────────────┘

During drag:

static canvas → unchanged
dynamic overlay → redraw each frame

Instead of:

entire document → redraw every pointermove

Example:

function drawDragFrame() {
overlayCtx.clearRect(0, 0, width, height);

drawSelectionBox();
drawDraggedObject();
drawGuides();
}

9. Avoid React in the Per-Frame Rendering Path

A major architectural mistake:

onPointerMove={(e) => {
setPosition({
x: e.clientX,
y: e.clientY,
});
}}

If this drives large React tree renders at pointer frequency:

pointermove
↓
setState
↓
React render
↓
reconciliation
↓
commit
↓
canvas redraw

That can become expensive.

For hot interaction state, I prefer:

const positionRef = useRef({ x: 0, y: 0 });

function onPointerMove(e: PointerEvent) {
positionRef.current.x = e.clientX;
positionRef.current.y = e.clientY;

requestRender();
}

React remains responsible for:

menus
toolbars
property panels
documents
selection metadata
persistent state
accessibility DOM

The renderer owns:

pointer position
drag interpolation
viewport transform
temporary guides
animation state

A useful rule:

React owns application state. The renderer owns frame state.


10. Batch Pointer Events With requestAnimationFrame

Pointer events can arrive faster than the display refresh rate.

Bad:

canvas.addEventListener("pointermove", (event) => {
updateSelection(event);
redrawScene();
});

If pointermove fires 150 times/sec:

150 redraws/sec

while the screen can only show ~60.

Better:

let latestEvent: PointerEvent | null = null;
let scheduled = false;

canvas.addEventListener("pointermove", (event) => {
latestEvent = event;

if (scheduled) return;

scheduled = true;

requestAnimationFrame(() => {
scheduled = false;

if (!latestEvent) return;

updateSelection(latestEvent);
render();
});
});

This coalesces many input events into one visual update.


11. Avoid Layout Thrashing

Canvas itself does not trigger DOM layout, but surrounding editor code often does.

Bad:

element.style.width = "100px";

const rect = element.getBoundingClientRect();

element.style.height = "200px";

const rect2 = element.getBoundingClientRect();

This pattern can force:

write
↓
layout
↓
read
↓
write
↓
layout
↓
read

Batch reads:

const rect = element.getBoundingClientRect();
const toolbarRect = toolbar.getBoundingClientRect();

Then writes:

element.style.transform = "...";
toolbar.style.transform = "...";

General rule:

READ → READ → READ
WRITE → WRITE → WRITE

not:

READ → WRITE → READ → WRITE

12. Cache Expensive Geometry

If shapes require expensive geometry:

new Path2D(...)
measureText(...)
bezier calculations
polygon tessellation
text wrapping

do not recompute every frame unless the object changed.

Bad:

function drawShape(shape) {
const path = computeComplexPath(shape);
ctx.stroke(path);
}

Better:

interface CachedShape {
geometryVersion: number;
cachedPath?: Path2D;
}

function getPath(shape: CachedShape) {
if (!shape.cachedPath) {
shape.cachedPath = computeComplexPath(shape);
}

return shape.cachedPath;
}

Invalidation:

shape property changes
↓
invalidate geometry cache
↓
recompute once

13. Pre-Render Expensive Objects

For complex reusable objects, render once to an offscreen surface.

const buffer = document.createElement("canvas");
const bufferCtx = buffer.getContext("2d")!;

drawComplexObject(bufferCtx);

Then:

ctx.drawImage(buffer, x, y);

This is useful for:

  • complex icons
  • repeated shapes
  • expensive vector groups
  • text blocks
  • static thumbnails

Tradeoff:

CPU ↓
memory ↑

So caches must be bounded.


14. OffscreenCanvas and Web Workers

If heavy rendering or preprocessing blocks the main thread:

Main Thread
│
├── input
├── React
├── layout
└── interaction

Worker
│
├── geometry
├── scene calculations
└── OffscreenCanvas rendering

Example:

const offscreen = canvas.transferControlToOffscreen();

worker.postMessage(
{
type: "INIT",
canvas: offscreen,
},
[offscreen]
);

Worker:

self.onmessage = (event) => {
if (event.data.type === "INIT") {
const canvas = event.data.canvas;
const ctx = canvas.getContext("2d");

// render in worker
}
};

But I would not jump to workers first.

Why?

Because workers do not solve:

drawing too many objects
poor culling
unnecessary redraws
GPU fill-rate limits
massive allocations

They mainly help when CPU work on the main thread is the problem.


15. Check Object Allocation and Garbage Collection

A bad rendering loop:

function render() {
const visible = objects.filter(...);
const points = objects.map(...);
const transforms = objects.map(...);

...
}

This can allocate thousands of temporary arrays and objects every frame.

At 60 FPS:

60 × thousands of allocations
↓
GC pressure
↓
frame pauses

Prefer:

reuse buffers
reuse arrays
reuse vectors
avoid temporary objects

Example:

const visibleObjects: SceneObject[] = [];

function collectVisible() {
visibleObjects.length = 0;

spatialIndex.queryInto(viewport, visibleObjects);
}

Look for GC slices in DevTools.


16. Reduce Canvas State Changes

This:

for (const object of objects) {
ctx.fillStyle = object.color;
ctx.globalAlpha = object.opacity;
ctx.lineWidth = object.lineWidth;

object.draw(ctx);
}

may result in many state changes.

Where possible, group by rendering state:

red objects
↓
blue objects
↓
green objects

instead of:

red
blue
red
green
blue
red

Likewise minimize unnecessary:

ctx.save();
ctx.restore();

in deeply nested loops.


17. High-DPI Canvas Can Multiply Work

A 1000 × 1000 CSS-pixel canvas at devicePixelRatio = 2 becomes:

2000 × 2000
= 4 million pixels

At DPR 3:

3000 × 3000
= 9 million pixels

So check:

window.devicePixelRatio

A large fullscreen canvas on a Retina display can dramatically increase raster cost.

Possible adaptive strategy:

const effectiveDpr =
interactionActive
? Math.min(devicePixelRatio, 1.5)
: Math.min(devicePixelRatio, 2);

During interaction:

quality slightly ↓
FPS ↑

After interaction:

rerender at full quality

This is a useful fallback for very large scenes.


18. Expensive Canvas Features

Some operations are substantially more expensive:

shadowBlur
filters
large gradients
globalCompositeOperation
huge transparent layers
large image scaling
text rendering
clipping paths
complex paths

For example:

ctx.shadowBlur = 40;
ctx.filter = "blur(12px)";

on many objects may be expensive.

Possible fixes:

pre-render effects
cache layers
disable effects during drag
reduce quality while interacting
use GPU renderer if needed

19. Large Images

A design editor may contain large images:

8000 × 8000 image

Even if displayed as:

500 × 500

you may still incur unnecessary decode/upload/memory costs.

Use resolution appropriate for current zoom:

zoomed out
↓
thumbnail

medium zoom
↓
medium resolution

zoomed in
↓
full resolution

Conceptually:

function chooseImageLevel(zoom: number) {
if (zoom < 0.25) return "thumbnail";
if (zoom < 1) return "medium";

return "full";
}

This is similar to mipmapping / level-of-detail.


20. Level of Detail

When zoomed far out, users cannot see fine detail anyway.

At:

5% zoom

do not draw:

individual text glyphs
tiny shadows
small handles
complex filters
fine strokes

Use:

if (zoom < 0.1) {
drawBoundingBox(object);
return;
}

Level-of-detail strategy:

Zoom < 10%
simple bounding boxes

10–50%
basic shapes

50–100%
normal rendering

>100%
detailed rendering

21. Canvas vs SVG vs WebGL

I would challenge the rendering technology if the scene size justifies it.

Canvas 2D​

Good:

moderate object counts
simple editor
easy immediate-mode rendering
text / shapes

Potential bottleneck:

CPU-driven draw calls

SVG​

Good:

smaller DOM-like vector scenes
semantic elements
easy hit testing
accessibility integration

Bad for:

tens of thousands of elements

because DOM overhead grows significantly.


WebGL / WebGPU​

Useful when:

100K+ visible primitives
large image compositing
GPU-friendly batching
filters
transform-heavy scenes

Architecture:

Scene Graph
↓
Render batches
↓
Vertex buffers
↓
WebGL/WebGPU
↓
GPU

I would not migrate to WebGL just because Canvas hits 20 FPS.

First prove that:

Canvas draw/raster is the bottleneck

after:

culling
caching
batching
LOD
invalidations

have been addressed.


22. Separate View Transform From Geometry

Panning should not recompute document geometry.

Bad:

pan
↓
recalculate every object's coordinates
↓
redraw

Better:

ctx.setTransform(
zoom,
0,
0,
zoom,
panX,
panY
);

Keep world coordinates stable.

Architecture:

World coordinates
│
▼
Camera transform
│
▼
Screen coordinates

This reduces expensive mutation of scene data.


23. Hit Testing Can Also Be the Bottleneck

Sometimes rendering looks slow, but pointer hit testing is actually consuming the frame.

Bad:

for (const object of allObjects) {
if (pointInsideObject(pointer, object)) {
...
}
}

For 100K objects:

O(N) per pointermove

Use the same spatial index:

const candidates = spatialIndex.queryPoint(pointer);

for (const candidate of candidates) {
preciseHitTest(candidate, pointer);
}

Pipeline:

pointer
↓
broad-phase spatial query
↓
~5 candidates
↓
precise geometry test

This mirrors collision detection in game engines.


24. Text Measurement Can Be Surprisingly Expensive

Editors often repeatedly call:

ctx.measureText(text);

for every frame.

Instead:

const key = `${font}:${fontSize}:${text}`;

const measurement = textMeasureCache.get(key);

Cache:

font + size + text
↓
width
height
baseline

Invalidate only when typography changes.


25. Progressive Quality During Interaction

A useful Staff-level optimization is to distinguish:

interaction quality

from:

final quality

While dragging:

disable shadows
use lower-resolution images
simplify paths
hide tiny details
reduce DPR

On pointerup:

render full fidelity

Flow:

pointerdown
↓
FAST MODE
↓
drag at 60 FPS
↓
pointerup
↓
HIGH QUALITY RENDER

This prioritizes responsiveness over unnecessary fidelity during motion.


26. Example Optimized Rendering Architecture

┌──────────────────────┐
│ React UI │
│ toolbar / panels │
└──────────┬───────────┘
│
│ commands
▼
┌───────────────┐ ┌──────────────────────┐
│ Pointer Input │────────────▶│ Interaction Manager │
└───────────────┘ └──────────┬───────────┘
│
│ invalidate
▼
┌──────────────────────┐
│ requestAnimationFrame│
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Viewport / Camera │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Spatial Index │
│ R-tree / quadtree │
└──────────┬───────────┘
│
visible objects only
│
▼
┌──────────────────────┐
│ Render Scheduler │
└──────────┬───────────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Geometry Cache │ │ Image / LOD │ │ Text Cache │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
└──────────────────────┼──────────────────────┘
▼
┌──────────────────────┐
│ Canvas / WebGL │
└──────────────────────┘

27. Concrete Optimization Sequence

I would optimize in this order.

Phase 1 — Measure​

Chrome Performance
React Profiler
frame timings
memory
GPU/raster metrics

Determine:

CPU?
GPU?
layout?
GC?
React?
render traversal?

Phase 2 — Eliminate unnecessary work​

Highest leverage:

viewport culling
event-driven rendering
rAF coalescing
dirty regions
layering
LOD

Phase 3 — Cache expensive work​

Path2D
text measurement
geometry
images
pre-rendered groups

Phase 4 — Remove main-thread pressure​

geometry → worker
image processing → worker
OffscreenCanvas → worker

Phase 5 — Upgrade renderer​

Only if needed:

Canvas 2D
↓
WebGL / WebGPU

28. Example Before and After

Assume:

100K total objects
2K visible objects

Before:

Scene traversal 12ms
Geometry 10ms
Canvas drawing 18ms
React 5ms
Layout 3ms
GC 2ms
───────────────────────────
Total 50ms

≈20 FPS

After:

Spatial query 1ms
Cached geometry 2ms
Canvas drawing 7ms
React 1ms
Layout 1ms
GC 1ms
───────────────────────────
Total 13ms

≈60 FPS

The key improvement is not:

"make drawRect 20% faster"

It is:

"stop drawing 98% of the scene."

29. Performance Budgets

I would establish explicit budgets.

Example:

Interaction frame:

Input processing < 1ms
Hit testing < 2ms
Scene update < 2ms
Visibility query < 1ms
Rendering < 8ms
Browser/GPU < 3ms
─────────────────────────────
Total < 16ms

Also track percentiles:

P50 frame time
P95 frame time
P99 frame time
long frames > 50ms

Average FPS alone can hide jank.

Example:

58 FPS average

can still feel bad if:

every second → 120ms frame

30. Production Observability

I would add Real User Monitoring rather than rely solely on local profiling.

Capture:

device class
browser
canvas dimensions
devicePixelRatio
scene object count
visible object count
zoom
image count
frame time
long frames
interaction latency

Example event:

{
event: "editor_frame_slow",
frameMs: 42,
totalObjects: 87342,
visibleObjects: 1642,
zoom: 0.7,
dpr: 2,
interaction: "drag",
}

Now we can answer:

Does the issue only occur:
- on Intel Macs?
- at DPR 3?
- with image-heavy documents?
- above 20K visible objects?
- while dragging?

31. Important Metrics

I would track:

FPS
frame time P50/P95/P99
input latency
long-task count
render duration
visible-object count
scene-object count
GC time
JS heap size
image memory
GPU texture memory if available
React commit duration

Business-facing UX metrics:

time to interactive editor
drag latency
zoom latency
selection latency
document-open latency

32. Staff-Level Interview Answer

A concise interview answer:

I would first establish whether 20 FPS is caused by JavaScript, layout, Canvas rasterization, GPU work, or garbage collection using Chrome Performance rather than guessing.

At 20 FPS we are spending about 50ms per frame against a 16.7ms target. I would decompose the frame into scene traversal, hit testing, geometry generation, Canvas draw time, React commits, layout, and GC.

Architecturally, my first suspicion in a large editor is unnecessary work. I would check whether we're walking and rendering the entire scene instead of only visible objects. I would introduce viewport culling using an R-tree or quadtree, make rendering invalidation-driven rather than continuously repainting, batch pointer updates through requestAnimationFrame, separate static and dynamic Canvas layers, and cache expensive paths, text measurements, and images.

I would also ensure React isn't in the per-frame interaction loop. React can own durable application state, while pointer position and transient drag state live in refs or the rendering engine.

If the main thread remains CPU-bound, I would move geometry or rendering to a worker using OffscreenCanvas. If profiling shows Canvas 2D itself is the bottleneck after culling and batching, then I would evaluate WebGL or WebGPU.

Finally, I would define a frame budget and ship telemetry for P95/P99 frame time, visible object count, scene size, DPR, and interaction type so we can prevent regressions.


33. Interviewer Follow-Up: "What If There Are 100K Objects?"

I would say:

100K total objects should not imply 100K draw calls.

Use:

R-tree
↓
viewport query
↓
500–2000 visible
↓
render only visible

For very large scenes:

spatial index
LOD
cached geometry
tile-based rendering
WebGL batching

34. Interviewer Follow-Up: "What If Panning Is Slow?"

Check whether panning causes:

geometry recomputation
React state updates
full scene traversal
image decoding
layout work

Ideal pan:

pointer delta
↓
camera transform update
↓
visibility query
↓
redraw

No persistent scene geometry should need to change.


35. Interviewer Follow-Up: "What If Dragging One Object Is Slow?"

Optimize the interaction:

static scene cached
+
drag overlay

During drag:

draw only:
- dragged object
- guides
- selection box

On drop:

update scene
rebuild spatial index entry
rerender affected region

36. Interviewer Follow-Up: "What If Zooming Is Slow?"

Possible causes:

high DPR raster
full-resolution images
text relayout
all geometry recomputed
LOD missing

Fix:

camera transform
LOD
image pyramids
temporary lower render quality
cached geometry

37. Interviewer Follow-Up: "Would You Use WebGL?"

My answer:

Possibly, but only after profiling. WebGL helps when the bottleneck is large numbers of GPU-friendly primitives, compositing, transforms, or effects. It does not fix poor scene traversal, bad hit testing, unnecessary redraws, excessive React renders, or layout thrashing. I would first reduce the amount of work, then change rendering technology if Canvas 2D remains the limiting factor.


38. Interviewer Follow-Up: "How Do You Prevent Regression?"

Add performance tests.

Example benchmark:

Load 100K-object document

Requirements:

pan P95 < 16ms/frame
zoom P95 < 20ms/frame
drag P95 < 16ms/frame
selection < 50ms
heap growth bounded

CI can compare:

baseline
↓
new build
↓
performance delta

Fail or alert when:

P95 frame time regression > 10%

39. Deep-Dive: Canvas Jitter

If users describe the problem as:

jitter
jumpiness
dragging behind cursor

I would investigate:

multiple event streams
pointermove > frame rate
layout reads in pointermove
React state synchronization
async stale updates
GC pauses
camera transform rounding
different coordinate spaces

Correct pattern:

Pointer events
↓
save latest pointer
↓
requestAnimationFrame
↓
read latest pointer once
↓
update simulation
↓
render exactly once

40. Deep-Dive: Stale Worker Results

If geometry is moved to a worker:

request A
request B
request C

The worker might return:

B
A
C

Use sequence IDs:

let latestRequestId = 0;

function computeGeometry(input: Input) {
const requestId = ++latestRequestId;

worker.postMessage({
requestId,
input,
});
}

worker.onmessage = (event) => {
const { requestId, result } = event.data;

if (requestId !== latestRequestId) {
return;
}

applyGeometry(result);
};

For document-level operations, use:

sceneVersion
objectVersion
requestId

to reject stale work.


41. Staff-Level Trade-Offs

Do not optimize everything simultaneously.

OptimizationBenefitCost
Spatial indexHuge for large scenesIndex maintenance
Dirty regionsFewer pixels drawnOverlap complexity
Layered canvasFast interactionMemory + synchronization
Geometry cacheLower CPUCache invalidation
OffscreenCanvasFrees main threadMessaging complexity
WebGLMassive batchingEngineering complexity
LODStrong zoom performanceRendering variants
Lower DPRFaster rasterTemporary visual quality

A Staff Engineer should explain not only:

what optimization exists

but:

when the complexity is justified

42. Final Mental Model

When a Canvas editor is slow, I walk through:

20 FPS
│
▼
Where is 50ms?
│
┌──────────────┼──────────────┐
▼ ▼ ▼
CPU GPU Browser
│ │ │
▼ ▼ ▼
scene traversal raster/fill layout
hit testing textures style
geometry effects compositing
React
GC
│
▼
Reduce work first
│
├── viewport culling
├── invalidation rendering
├── rAF batching
├── layering
├── caching
├── LOD
│
▼
Move work
│
├── Worker
└── OffscreenCanvas
│
▼
Upgrade renderer if needed
│
└── WebGL / WebGPU

The most important Staff-level principle:

The fastest frame is the frame where you avoid doing work at all.