Performance
How the components stay out of the way of audio and animation, and how that is measured on every change.
In a DAW, the interface competes with audio and with dozens of animated meters. Performance is a requirement of the library, and it is measured, not assumed.
How the components work
- Styling runs no code per render.
classNameandstyletake plain values; state reaches CSS through data attributes and CSS variables, and thresholds through zones. A drag commits on every pointer event and automation on every frame, so anything computed per render is multiplied by the channel count and the frame rate. - Values never render through React. When the value of a knob, fader or number box changes, by a drag, a key, or automation, each part writes what it shows (an attribute, a CSS variable, its text) straight to the DOM, and only what differs. Dragging a knob renders nothing, not even the knob.
- Values that change on their own are read, not pushed. Meters,
readon knobs and faders, andKnob.Modulationare called once per animation frame by one sharedrequestAnimationFrameloop, instead of flowing through props and renders. The accessible value of a meter updates four times a second. - An interaction renders only what it changes. Toggles in a group subscribe to their own state: painting one step of a 16 × 64 sequencer renders that step, not the grid.
- Playback touches only what follows the playhead. The playhead's position is written as a number into the playhead and the played parts of waveforms, not as a CSS variable: a variable on the timeline would recalculate the style of every element under it on every frame. Scrolling and zooming do change the variables every element on the timeline uses, so they cost more than playback, in proportion to the number of elements.
- Drawing happens when what is drawn changes, not when it moves. Waveforms draw canvas tiles once and let CSS place them in time: playback and scrolling draw nothing already drawn. What must be drawn goes through one queue that spends at most 4 ms per frame, visible tiles first.
- Layout is read once per gesture. A paint stroke reads the boxes of the toggles, or of a bar graph's items, when it starts, then tests the pointer's path against them.
- Formatters are created once, not per frame.
What is measured
Every change to the repository runs stress pages, in a production build, in Chromium with the CPU slowed down 4×: 64 channel strips with running meters, pan knobs, faders and mute and solo buttons, a 16 × 64 step sequencer, 64 channels whose knobs and faders follow automation on every frame, a timeline of 16 tracks with 32 four-minute clips under one playhead, a ruler and a grid, a take being recorded, a MIDI track and an automation lane, a keyboard of 88 keys, a bar graph of 64 values, and eight live spectra.
| Scenario | Main thread per frame (ms) | Of which JavaScript (ms) | Dropped frames | Input → frame p50 / p95 (ms) | React commits |
|---|---|---|---|---|---|
| 64 meters running | 5.0 | 0.2 | 0 of 181 | – | 0 |
| Fader drag, meters running | 8.3 | 0.4 | 0 of 91 | 9.8 / 11.2 | 0 |
| Paint 64 steps, meters running | 7.4 | 1.1 | 1 of 89 | 10.1 / 11.8 | 32 for 30 moves |
Automation on 128 knobs and faders, through read | 12.2 | 2.7 | 1 of 180 | – | 0 |
The same automation through React state (value) | 19.4 | 9.9 | 24 of 157 | – | one per frame |
| 32 waveforms on a timeline with a ruler and grid, playback | 2.8 | 0.1 | 0 of 181 | – | 0, and no tile drawn |
| The same, the view paging along with playback | 4.6 | 0.2 | 0 of 181 | – | 0, 4 tiles drawn in 3 s |
| The same, recording a 33rd take | 3.5 | 0.3 | 0 of 181 | – | 0, one tile drawn per frame |
| The same, zooming without pause | 8.9 | 0.8 | 4 of 177 | – | 0 |
| The same, paging along, with a MIDI track of 3760 notes | 4.9 | 0.2 | 0 of 181 | – | 0, 4 tiles drawn in 3 s |
| The same, zooming, with an automation lane of 2000 bent points | 9.1 | 0.8 | 4 of 177 | – | 0 |
| The same, playing, dragging a point of that lane, editable | 4.0 | 0.5 | 0 of 90 | 10.4 / 13.5 | 2: the press, and keeping the points |
88 keys lit by playback through read | 0.4 | 0.0 | 0 of 181 | – | 0 |
| A glissando across 88 keys | 1.1 | 0.1 | 0 of 90 | 14.2 / 15.4 | 0 |
| A stroke across 64 values of a bar graph | 1.0 | 0.3 | 1 of 90 | 14.8 / 15.7 | 0 |
| 8 spectra of 2048 bins, read every frame | 5.1 | 0.7 | 0 of 181 | – | 0 |
A 60 Hz frame has 16.7 ms. The two automation rows are the same page driven two ways: controlled value props updated on every frame spend more than the whole budget, read leaves room.
The numbers that do not depend on the machine fail the build when they get worse: React commits per interaction (zero for drags, for automation through read, for running meters, for waveforms and for keys), canvas tiles drawn (none during playback), renders per painted step, and the bundle size, minified and gzipped: 40.6 kB for everything, and what an application adds when it imports only some components, such as 7.8 kB for a knob alone or 4.8 kB for a keyboard alone, which checks that the rest is left out. Timings are reported in the CI job summary, because shared CI machines are too noisy to fail on them.
In your application
- Pass values that change on their own with
read, not withvalueorlevel: a meter's level, automation playback, a parameter moved by a control surface.readis called once per frame and renders nothing; a prop renders its parent. - Controlled components render when their parent does. For a mixer with many channels, keep each channel strip in a memoized component, or leave the controls uncontrolled and send
onValueChangestraight to your audio engine, as the playground does.