A measured comparison of Figma, tldraw, Excalidraw, and Skedoodle, and the architectural choice that makes the difference.
Open your browser. Go to any drawing or whiteboarding app: tldraw, Excalidraw, Figma, whatever you use. Put it on a blank canvas. Don't touch anything.
Open your browser's task manager.
That app is probably using 1–3% CPU right now. Not the browser as a whole. Not all your tabs combined. Just that one page, sitting there, doing nothing visible. Figma alone burns 3.49%. Multiply across every "modern web app" tab you keep open and you start to understand why your fan spins up when you're not using the computer.
I wanted to know where that CPU was going. I built a Playwright rig, loaded tldraw, Excalidraw, and Figma on a blank canvas, and sampled CPU for 30 seconds across 5 runs. I also measured a drawing app of my own, Skedoodle.
Here's the result:
Three apps pay a tax. One sits at the measurement noise floor. This post is about where each one's idle CPU goes, and then about a more surprising finding underneath: rendering architecture isn't what determines active CPU.
Methodology in one paragraph
I built a small Playwright-based perf framework that opens each app in Chromium, sits on a blank canvas for 30 seconds, and samples Chrome DevTools Protocol Performance.metrics every 500ms. The reported "CPU%" is ΔTaskDuration / wall_clock, attributed to the page, not the whole browser process. Median of 5 runs; whiskers on the chart are min–max. Machine: Microsoft Surface, Intel Core i5-1035G7, 8 cores, Arch Linux. The full methodology and raw data are in the repo. pnpm --filter skedoodle-perf baseline reproduces every number in this post.
Why tldraw ticks every frame
tldraw ships a component called TickManager. It does what the name suggests: runs forever. Here's the relevant code:
start() {
this.isPaused = false
this.cancelRaf?.()
this.cancelRaf = throttleToNextFrame(this.tick)
this.now = Date.now()
}
@bind
tick() {
if (this.isPaused) { return }
const now = Date.now()
const elapsed = now - this.now
this.now = now
this.editor.inputs.updatePointerVelocity(elapsed)
this.editor.emit('frame', elapsed)
this.editor.emit('tick', elapsed)
this.cancelRaf = throttleToNextFrame(this.tick) // re-arm for next frame
}
Every frame (60Hz), tick runs and re-schedules itself via requestAnimationFrame. No dirty-flag guard. It ticks regardless of whether anything changed.
And the tick does real work. It updates pointer velocity even when the pointer hasn't moved. It drains an event queue that might be empty. It fires 'frame' and 'tick' to anyone listening. The listeners do their own work: viewport and camera animation checks, scribble handlers (no-op when idle, but still a function call), and a PerformanceManager._onFrame that computes getCulledShapes() on every frame.
None of that is wasted effort inside tldraw's model. Pointer velocity enables gesture recognition and flick handling. Frame events drive camera tweens and smooth zoom-to-fit. Culling keeps active-draw fast at scale. If you want those features, something has to run the tick. Multiply 60 ticks by half a millisecond of work each and you get ~1.5% CPU as the price of having them.
Skedoodle doesn't have most of those features. So it doesn't tick.
Excalidraw's React reconciler
Excalidraw does not run a perpetual rAF. Their throttleRAF helper is pull-based; it only schedules when called:
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
let timerId: number | null = null;
let lastArgs: T | null = null;
const scheduleFunc = () => {
timerId = window.requestAnimationFrame(() => {
timerId = null;
const args = lastArgs;
lastArgs = null;
if (args) { fn(...args); }
});
};
// ...
};
And their AnimationController explicitly stops itself when there's nothing left to animate:
if (AnimationController.animations.size === 0) {
AnimationController.isRunning = false;
return; // loop stops here when idle
}
So where does Excalidraw's 1.18% idle CPU go? Into React's reconciler. Excalidraw stores hover state, current tool, and pointer position in React component state. Each setState triggers componentDidUpdate, which runs a ~160-line prev/next diff, commits to its store, fires onChange listeners, and toggles theme classes.
This is a reasonable design choice. Keeping interaction state in React gives you the normal React ergonomics: declarative rendering, hooks, standard event handling. The cost is that any internal state change wakes the reconciler, and at idle there's still enough internal churn (hover ticks, mouse-move handlers, periodic state sync) to keep it awake on a steady cadence.
It's a different shape of problem from tldraw's: not a perpetual rAF, but a steady drip of React work, landing at roughly the same cost.
Figma is a different kind of cost
Figma's idle CPU is the highest of the four, and most of it isn't rendering. At idle, the page is running: a websocket keepalive to Figma's backend, CRDT bookkeeping for the file you have open, cursor-presence logic for other collaborators, autosave timers, and the usual authenticated-product chatter (telemetry, experiment assignment, analytics).
The perf runs for the other three apps (tldraw OSS, Excalidraw, Skedoodle) were local and unauthenticated. None of them were paying for collab infrastructure at measurement time. Figma doesn't offer a local-only mode, so its number reflects a shipping collaborative product rather than a fair architectural peer. Keep it in the chart as a ceiling on "what a production collaborative whiteboard costs idle," not as a comparison against the other three.
The rest of this post is about the other three.
Skedoodle's 0.09%: event-driven rendering
Skedoodle's idle CPU is near zero because nothing polls. The canvas doesn't repaint unless a user event changed the scene. State mutations don't wake a reconciler because canvas state isn't in React. There's no equivalent of tldraw's TickManager.
Two choices enforce this.
The renderer's internal loop is disabled. Skedoodle is built on Two.js, a thin 2D renderer. Two.js's default is its own internal requestAnimationFrame loop, the autostart: true option. Enable it, and Two.js will call update() every frame for you, forever. If it were left on, Skedoodle would measure about the same as tldraw.
The first line of Skedoodle's canvas setup turns it off. client/src/canvas/canvas.hook.tsx:
return new Two({
autostart: false,
fitted: true,
width: container.clientWidth,
height: container.clientHeight,
type: twoType,
}).appendTo(container);
With autostart: false, Two.js never calls update() on its own. Something in the application has to call it.
The application only calls update() on user events. Skedoodle's entire render-scheduling layer is this one method on the canvas manager:
throttledTwoUpdate = () => {
const updateFrequency = useOptionsStore.getState().updateFrequency;
if (updateFrequency === 0) {
this.two?.update?.();
} else {
if (!this._throttledUpdate || this._lastFrequency !== updateFrequency) {
this._lastFrequency = updateFrequency;
this._throttledUpdate = throttle(() => {
this.two?.update?.();
}, updateFrequency);
}
this._throttledUpdate();
}
};
Three things to notice.
First, the function reads updateFrequency from a Zustand store on every call. That's deliberate: the user can change the throttle rate from the UI at runtime, and the next invocation picks up the new value without re-instantiation.
Second, if updateFrequency is 0, the call goes through immediately with no throttling. This is "High Performance" mode in the UI. For interactions like dragging a single shape or editing a bezier handle, unthrottled gives the most responsive feel and costs nothing extra, because the call rate is already bounded by the pointer event rate.
Third, for any non-zero frequency, Skedoodle builds a throttled wrapper using lodash's throttle (leading + trailing edges) and caches it. The cache is keyed on the frequency value, so changing the throttle rate invalidates and rebuilds; otherwise the same wrapper is reused across calls.
Who calls this? Tool handlers do, after they've mutated scene state — the brush tool on every pointer-move, the shape tool after adjusting dimensions, the pointer tool on selection changes. Zustand store mutations that affect scene state call it. Nothing else. When the user is sitting still, throttledTwoUpdate() isn't called, two.update() doesn't run, and the canvas doesn't repaint.
That's the 0.09%. It isn't a trick. It's what's left when you remove everything that was polling.
The throttle rate (10, 30, 60, or 120 FPS, or "High Performance" for unthrottled) is exposed in the Settings panel as "Update Frequency." That last detail matters: it's evidence that the event-driven model is product surface, not accidental. A thick-library architecture couldn't offer that knob, because the library owns its own tick rate.
The tie that's the real story
Here's what happens when everyone's actually drawing: a synthesized 15-second pointer trace (Archimedean spiral, 60 Hz, 902 events) replayed identically across all four apps.
Skedoodle and tldraw land 0.23 percentage points apart on median CPU across five runs. That's noise-floor territory, and it's the finding that most changed how I think about this.
These two apps have nothing architecturally in common on the rendering side. Skedoodle uses Two.js's SVG renderer. tldraw built its own React-plus-canvas rendering stack from scratch. Totally different choices. Same cost.
Which means active-draw CPU is not determined by which rendering library you pick. It's determined by whether your app does anything else while rendering. tldraw ticks every frame and drains the queue; Skedoodle runs its throttled update. Both do roughly the same amount of shape-drawing work per user event. Same number.
Excalidraw is ~8 points higher, almost certainly rough.js doing stroke roughening on every pointer event. Figma saturates a CPU core: every stroke routes through a WASM renderer, a CRDT, autosave persistence, and telemetry. Different cost structure entirely.
Put together: idle cost and active cost are different problems. Idle is about what your app does when no one's asking it to do anything, which is architectural. Active is about how much work each user interaction triggers, which is workload-dependent. The first is a design choice. The second is mostly inherent.
Picking Two.js over tldraw won't make drawing faster. Picking an event-driven architecture over a perpetual tick will make your app disappear when no one's using it.
The tax
There's a reason most drawing apps use something thicker than Two.js. With a thin renderer you write your own interaction layer: selection, hit-testing, handles, undo/redo, snapping, the whole surface. In Skedoodle's case that's roughly 5,000 lines of application code that exists specifically because the library didn't provide it. tldraw, Fabric, and Konva give you all of that.
Other costs worth being honest about:
- You re-discover bug classes. An early version of Skedoodle had a selection/hover layering bug where selection chrome rendered beneath newly drawn shapes; a mature transformer library would have prevented it by owning the chrome layer. Similar categories (pointer capture during fast drags, z-ordering of rotation handles against content, hit-testing that treats stroked paths as fills, coordinate math that breaks at extreme zoom levels) are things a library like tldraw or Konva has already worked through. With a thin renderer, you encounter them yourself, usually after they ship.
- Upgrade path is closer to the metal. When the library has a bug, it's more likely to be your problem.
The flip side of "own your render loop" is "own your interaction stack." It's not free, just moved.
When not to do this
Three workloads where event-driven rendering stops helping:
- Scenes that legitimately need every frame. Tween systems, physics, particle effects, animated cursors. If the scene changes without user input, an event-driven loop has nothing to trigger it. You need a tick. tldraw's model fits.
-
Thousands of continuously moving shapes. When redraws are expensive and frequent, the cost isn't in whether to call
update(), it's in whether the renderer can batch, dirty-rect, or cull. Thin renderers without those primitives stop helping. - Transformer-heavy interaction surfaces. If your product is defined by multi-select, rotation handles, and snapping across transformed groups, the LOC cost of building that yourself is large and front-loaded. tldraw's transformer is legitimately good. Buy it; don't rebuild it.
Skedoodle's workload is sparse updates driven by user input. Event-driven fits. If it were a particle simulator, I'd want every frame to fire and I'd run a TickManager too.
Try it yourself
The perf framework is committed alongside the Skedoodle source, with a written-down methodology and a 5-run baseline. pnpm install && pnpm --filter skedoodle-perf baseline reproduces every number in this post. Figma needs a one-time auth capture, and the README walks through it.
The architectural choice here is event-driven rendering: nothing polls, renders happen because something changed. autostart: false enforces it at the Two.js boundary. throttledTwoUpdate enforces it inside the application. Neither alone is the whole story; the combination is.
Your drawing app doesn't have to use 2% CPU when you're not using it. It uses that much because of a choice.
Source: github.com/eugenioenko/skedoodle. The perf framework lives in the perf/ directory; baseline numbers in perf_results.md; the research notes that became this post in article_notes.md.


Top comments (0)