Summary
This PR performs a comprehensive performance overhaul of Echo UI, focusing on the two largest sources of overhead identified during the component-library audit:
- The published ESM bundle does not tree-shake effectively, causing small imports such as
Button, Card, or a single icon to retain most of Echo UI and Tone.js.
- Real-time audio visualizations allocate large arrays, trigger React updates, and rebuild SVG nodes on every animation frame.
The changes in this PR improve:
- Consumer bundle size and module isolation
- Tree-shaking for individual components and hooks
- Real-time audio sampling efficiency
- Spectrogram and oscilloscope rendering
- Drag interaction scheduling
- Controlled-component render behavior
- ResizeObserver lifecycle management
- Waveform preprocessing and rendering
- Shared audio fetching and decoding
- Performance regression coverage
Motivation
Echo UI is intended to be a high-performance UI library for Web Audio applications. The current implementation works correctly, but several architectural patterns become expensive when applications render multiple real-time components or run on lower-end devices.
The performance audit found two release-blocking issues and several high-priority runtime issues.
Published bundle baseline
Before this work:
| Consumer scenario |
Minified JS |
Gzip |
Full dist/echo-ui.js |
213,766 B |
56,341 B |
Import Button, excluding React only |
474,811 B |
115,244 B |
Import Card, excluding React only |
474,809 B |
115,242 B |
| Import one icon, excluding React only |
475,427 B |
115,583 B |
Import Button, excluding React and Tone |
183,492 B |
50,830 B |
This showed that importing a basic component still retained Tone.js, D3-related code, and most of the library.
For comparison, bundling Button directly from the source module produced approximately 13.5 kB gzip. The difference was caused by the published package structure rather than the component itself.
Runtime baseline
A production example was measured in headless Chromium while running:
- One
Waveform
- One
Oscilloscope
- Two
Spectrogram instances
Over a three-second measurement window, the page produced:
- 5,611 DOM mutation records
- 905 removed DOM nodes
- 1,810 added DOM nodes
- 1,810 SVG path attribute updates
- Approximately 1.07 seconds of main-thread task time
- Approximately 0.78 seconds of script execution time
The test machine still maintained approximately 60 fps, but the active visualizations consumed a large part of the available frame budget. This leaves limited capacity for application logic, effects, layout, and lower-performance devices.
The drag controls also failed to coalesce high-frequency pointer input:
| Component |
Synthetic move events |
RAF callbacks queued |
| Input |
500 |
500 |
| Knob |
500 |
500 |
| Slider |
500 |
500 |
Changes
1. Restore package-level tree-shaking
- Split the public API into explicit component, visualization, and hook entry points.
- Keep the ESM build modular instead of publishing one pre-bundled module containing every component.
- Build the UMD artifact separately from the tree-shakeable ESM output.
- Prevent non-audio components from importing or retaining Tone.js.
- Prevent basic controls and icons from retaining D3 visualization code.
- Add package exports for focused imports where appropriate.
- Preserve the existing root export for backward compatibility.
- Ensure CSS remains correctly marked as a side effect while JavaScript modules remain tree-shakeable.
Expected import patterns include:
@nafr/echo-ui
@nafr/echo-ui/button
@nafr/echo-ui/card
@nafr/echo-ui/visualization/spectrogram
@nafr/echo-ui/hooks/use-player
The root entry remains available, but consumers can select narrower entry points when bundle isolation is important.
2. Add consumer bundle-size regression checks
Add production consumer fixtures that bundle representative imports:
Button
Card
- A single icon
Spectrogram
usePlayer
- Root package import
The verification checks ensure that:
- A basic component does not contain Tone.js.
- A basic component does not contain D3 visualization code.
- A single icon does not retain the component library.
- Hook entry points contain only their required runtime dependencies.
- Published package exports resolve correctly in ESM and CommonJS consumers.
- Bundle-size regressions fail CI instead of relying on manual inspection.
Target budgets:
| Import |
Target gzip budget |
| Single icon |
< 2 kB |
| Basic control |
< 20 kB |
| Card |
< 20 kB |
| Audio hook, excluding Tone peer/runtime |
< 10 kB |
| Visualization component |
Component-specific budget documented in the fixture |
3. Centralize real-time animation scheduling
Update useOscilloscope, useSpectrogram, useVuMeter, and usePlayer so they do not independently push unrestricted updates through React on every display frame.
The new scheduling behavior should:
- Allow a configurable maximum update rate.
- Avoid scheduling more than one pending animation frame.
- Stop scheduling immediately after an error.
- Stop scheduling when the hook is canceled or unmounted.
- Pause or reduce work while the document is hidden.
- Support offscreen suspension where an element reference is available.
- Avoid publishing state when the sampled value has not changed meaningfully.
- Separate audio sampling frequency from React rendering frequency.
Recommended defaults:
- Oscilloscope: up to 30 fps
- Spectrogram: up to 30 fps
- VU meter: 30–60 fps, depending on smoothing
- Player progress: 15–30 fps
Audio analysis may continue at the required internal rate, but React and DOM updates should be throttled independently.
4. Reduce per-frame analyzer allocations
Before this work, analyzer hooks converted typed arrays into newly allocated object arrays on every frame.
For example, a 1,024-point analyzer could allocate thousands of objects per second before the visualization performed its own transformation.
This PR should:
- Keep analyzer output in typed arrays where possible.
- Avoid
Array.from(...).map(...) in frame loops.
- Avoid recreating
{ index, amplitude } and { frequency, amplitude } objects every frame.
- Reuse preallocated buffers when the analyzer size is unchanged.
- Calculate frequency coordinates once when
fftSize changes.
- Avoid transforming the same spectrum independently for line and shadow rendering.
5. Reuse visualization nodes
Spectrogram
- Stop removing and appending line and shadow groups on every frame.
- Create static SVG structure during initialization.
- Reuse the existing line and shadow paths.
- Update only the required path data.
- Reuse scales until dimensions or scale-related props change.
- Calculate grid and axis layers only when their configuration or dimensions change.
- Ensure gradient IDs remain unique across multiple component instances.
- Avoid recalculating the same transformed spectrum for both line and shadow rendering.
Oscilloscope
- Reuse a single path instead of replacing its parent group every frame.
- Keep scales stable while dimensions, data length, and amplitude range are unchanged.
- Avoid recreating curve generators during each data update.
- Prefer a typed-array or index-based path generator without intermediate object allocation.
Rendering backend
Where SVG path updates remain too expensive, evaluate Canvas or OffscreenCanvas for real-time layers while retaining SVG for static axes and labels.
Acceptance criteria:
- Stable playback produces no per-frame
childList mutations.
- Grid and axis nodes are not recreated by audio data updates.
- Each visualizer has at most one live render task per frame.
- Multiple visualizer instances do not share conflicting SVG IDs.
6. Coalesce drag interactions
Update Input, Knob, and Slider to use a single-frame pointer scheduler.
Changes include:
- Use Pointer Events instead of separate document-level mouse handlers.
- Use pointer capture to keep drag ownership on the active control.
- Store only the latest pointer coordinates.
- Allow at most one pending RAF callback.
- Cancel the pending callback on drag end and unmount.
- Remove global listeners during cleanup.
- Avoid calling
onChange more than once per animation frame.
- Preserve a final synchronous value update for
onChangeEnd.
Acceptance criteria:
- A burst of 500 pointer events queues no more than one pending RAF.
- No pointer listener or RAF callback survives component unmount.
onChangeEnd receives the final clamped value.
- Drag behavior remains correct for horizontal, vertical, and bilateral controls.
7. Remove render-phase state updates
Remove setState calls from render-time calculations and useMemo.
Affected behavior includes:
Input setting direction while calculating its background
Slider setting direction while calculating progress styles
- Controlled values mirrored into local state through effects
- Values that can be derived directly from props or existing state
Direction and other derived values should be calculated as plain variables unless they represent independent user state.
Acceptance criteria:
- No component calls a state setter during render.
- Updating a controlled value requires no more than one component commit.
- React Strict Mode produces no render-phase update warnings.
- Controlled and uncontrolled behavior remains consistent.
8. Simplify render waterfalls
Reduce prop-to-state synchronization in:
Checkbox
Input
Knob
Light
Switch
VuMeter
Waveform
Examples:
Light should render directly from on instead of copying on into state.
Waveform should not copy percentage through an effect when it is controlled.
VuMeter should derive an active index from value rather than storing a complete array of binary lump values.
Knob should derive rotation from the current value without a second effect-driven render.
- Group-controlled checkbox and radio state should not require a child-local synchronization pass.
9. Optimize VU meter rendering
The VU meter currently rebuilds arrays of 30 or 60 lump values and traverses every rendered span for each audio update.
This PR should:
- Store or derive a numeric active-lump boundary.
- Avoid allocating full binary arrays for every sample.
- Keep Context values stable.
- Memoize group-level values and handlers.
- Update only lump nodes whose active range changed.
- Review the 150ms color transition, which can remain continuously active during 60fps meter updates.
- Ensure stereo rendering does not duplicate avoidable work.
Acceptance criteria:
- One meter value update produces at most one React commit.
- No full binary lump array is allocated per frame.
- Stable meter input causes no React update.
- Stereo and mono output preserve their current visual thresholds.
10. Optimize Waveform rendering and interaction
- Avoid rendering two complete copies of the waveform path for progress masking.
- Reuse one waveform definition with an SVG clip path or mask where possible.
- Avoid animating large SVG
d attributes during initialization.
- Cache the component bounds during pointer entry or resize.
- Do not call
getBoundingClientRect() for every mouse move.
- Coalesce hover updates to one update per frame.
- Move the cursor using
transform: translateX(...) instead of left.
- Avoid three independent state updates for each pointer move.
- Respect reduced-motion preferences for the initial waveform animation.
Acceptance criteria:
- Playback progress updates do not rebuild waveform paths.
- Hovering does not cause repeated forced layout reads.
- The waveform cursor updates through transform-only styles.
- A progress tick produces at most one React commit.
11. Fix ResizeObserver scheduling
The previous throttle implementation invoked the callback immediately and then invoked it again after 100ms even when no additional resize occurred. Pending timeouts were not canceled during cleanup.
This PR should:
- Replace the custom throttle with a cancellable scheduler.
- Avoid unconditional trailing invocations.
- Keep the latest callback in a ref or require a stable callback.
- Disconnect the observer and cancel pending work during cleanup.
- Remove mutable data objects from observer-effect dependencies.
- Trigger one redraw for one stable resize event.
Affected components include:
- Envelope
- LFO
- Oscilloscope
- Spectrogram
- Waveform
12. Optimize Envelope drag rendering
- Reuse existing path and circle nodes.
- Avoid removing and recreating every node during controlled feedback.
- Avoid rebinding D3 drag handlers on every value update.
- Keep transient drag coordinates outside React where appropriate.
- Publish at most one
onChange callback per frame.
- Commit normalized envelope data on drag end.
- Avoid recreating the ResizeObserver whenever envelope data changes.
13. Move waveform preprocessing off the main thread
useWaveform currently walks all PCM frames synchronously when a new AudioBuffer is supplied.
This PR should:
- Support Worker-based peak generation for long audio.
- Yield or process in chunks when Worker execution is unavailable.
- Cache results by audio source, channel count, and requested sample count.
- Reuse previously generated peak levels when changing visualization width.
- Document the behavior for very large or long audio files.
Acceptance criteria:
- Long audio processing does not create a main-thread task longer than 50ms on the target benchmark device.
- Switching back to an already processed source reuses cached data.
- Changing only playback progress does not rerun waveform preprocessing.
14. Deduplicate audio fetching and decoding
Improve useFetchAudio so multiple components using the same URL do not independently download and decode identical audio.
Possible implementation:
- Add an optional shared request/decode cache.
- Deduplicate in-flight requests.
- Cache resolved
AudioBuffer values with explicit invalidation.
- Reuse a decoding AudioContext instead of creating one context per request.
- Preserve AbortSignal and request-version behavior.
- Ensure failed and aborted requests are not retained as successful cache entries.
This behavior should remain opt-in or clearly documented if global caching would change existing lifecycle expectations.
15. Stabilize Context and class-generation work
- Memoize Context values in Button, Checkbox, Radio, Knob, and VU meter groups.
- Stabilize group change handlers with
useCallback.
- Avoid propagating group rerenders when inherited values have not changed.
- Avoid running
tailwind-merge repeatedly inside frame-driven components when class inputs are static.
- Precompute static style-slot output outside hot render paths where possible.
Component impact
| Component |
Main performance concern |
| Button / Button.Group |
Package retention, Context identity, class merging |
| Checkbox / Checkbox.Group |
Mirrored controlled state, Context propagation |
| Envelope |
Drag feedback, SVG rebuilding, observer recreation |
| Input |
Uncoalesced drag events, render-phase direction updates |
| Knob / Knob.Group |
Multiple render stages per value, uncoalesced drag events |
| Radio / Radio.Group |
Context identity and prop-merging allocations |
| Slider |
Uncoalesced drag events and layout-property updates |
| Switch |
Mirrored state and left animation |
| VuMeter |
Per-frame arrays, double rendering, Context churn |
| Light |
Mirrored state, synchronous color parsing, expensive paint effects |
| LFO |
Fixed 1,000-point sampling and full SVG redraw |
| Spectrogram |
Per-frame allocation and SVG node replacement |
| Oscilloscope |
Per-frame allocation and SVG node replacement |
| Waveform |
Duplicate SVG paths, layout reads, progress render waterfall |
| Axis |
Two-stage layout measurement and full axis recreation |
| Card |
No component-specific hot loop; affected by package retention |
| Icons |
No component-specific runtime issue; affected by package retention |
Backward compatibility
- Preserve the existing root package entry.
- Preserve current component props unless a change is explicitly documented.
- Preserve existing controlled-component semantics.
- Keep existing Tone node access through hook refs.
- Keep existing CSS entry points.
- Treat new focused exports as additive.
- Document any new scheduling props such as
maxFps, pauseWhenHidden, or cache configuration.
Testing
Existing checks
New performance regression tests
Browser validation
Test the following scenarios in a production build:
- One active oscilloscope
- One active spectrogram with shadow
- Mono VU meter
- Stereo VU meter
- Waveform playback progress
- Two spectrograms, one oscilloscope, and one waveform running together
- Twenty simultaneous VU meters
- Rapid Input, Knob, and Slider dragging
- Components entering and leaving the viewport
- Page visibility switching between visible and hidden
Performance acceptance criteria
Before/after results
Update this table with the final measurements before merging:
| Metric |
Before |
After |
Target |
Button consumer gzip, React excluded |
115.24 kB |
TBD |
< 20 kB |
Button consumer gzip, React and Tone excluded |
50.83 kB |
TBD |
< 20 kB |
| Full ESM gzip |
56.34 kB |
TBD |
No regression |
| Four active visualizers: DOM mutations / 3s |
5,611 |
TBD |
No per-frame child replacement |
| Four active visualizers: main-thread task time / 3s |
1.07s |
TBD |
Material reduction |
| Four active visualizers: script time / 3s |
0.78s |
TBD |
Material reduction |
| Input RAF callbacks for 500 move events |
500 |
TBD |
≤ 1 pending |
| Knob RAF callbacks for 500 move events |
500 |
TBD |
≤ 1 pending |
| Slider RAF callbacks for 500 move events |
500 |
TBD |
≤ 1 pending |
Risks
- Changing package exports may affect resolution in older bundlers.
- Moving from object arrays to typed arrays may require internal type changes.
- Reducing UI update frequency can change the visual feel of meters.
- Canvas rendering can affect styling and testing strategies.
- Audio caching changes ownership and memory-lifetime expectations.
- Pointer Events require regression testing for mouse, touch, and pen input.
- Removing mirrored state can expose previously ambiguous controlled/uncontrolled behavior.
These areas should be covered by compatibility fixtures, browser tests, and release notes.
Documentation
Related issue
Closes #
Summary
This PR performs a comprehensive performance overhaul of Echo UI, focusing on the two largest sources of overhead identified during the component-library audit:
Button,Card, or a single icon to retain most of Echo UI and Tone.js.The changes in this PR improve:
Motivation
Echo UI is intended to be a high-performance UI library for Web Audio applications. The current implementation works correctly, but several architectural patterns become expensive when applications render multiple real-time components or run on lower-end devices.
The performance audit found two release-blocking issues and several high-priority runtime issues.
Published bundle baseline
Before this work:
dist/echo-ui.jsButton, excluding React onlyCard, excluding React onlyButton, excluding React and ToneThis showed that importing a basic component still retained Tone.js, D3-related code, and most of the library.
For comparison, bundling
Buttondirectly from the source module produced approximately 13.5 kB gzip. The difference was caused by the published package structure rather than the component itself.Runtime baseline
A production example was measured in headless Chromium while running:
WaveformOscilloscopeSpectrograminstancesOver a three-second measurement window, the page produced:
The test machine still maintained approximately 60 fps, but the active visualizations consumed a large part of the available frame budget. This leaves limited capacity for application logic, effects, layout, and lower-performance devices.
The drag controls also failed to coalesce high-frequency pointer input:
Changes
1. Restore package-level tree-shaking
Expected import patterns include:
@nafr/echo-ui@nafr/echo-ui/button@nafr/echo-ui/card@nafr/echo-ui/visualization/spectrogram@nafr/echo-ui/hooks/use-playerThe root entry remains available, but consumers can select narrower entry points when bundle isolation is important.
2. Add consumer bundle-size regression checks
Add production consumer fixtures that bundle representative imports:
ButtonCardSpectrogramusePlayerThe verification checks ensure that:
Target budgets:
< 2 kB< 20 kB< 20 kB< 10 kB3. Centralize real-time animation scheduling
Update
useOscilloscope,useSpectrogram,useVuMeter, andusePlayerso they do not independently push unrestricted updates through React on every display frame.The new scheduling behavior should:
Recommended defaults:
Audio analysis may continue at the required internal rate, but React and DOM updates should be throttled independently.
4. Reduce per-frame analyzer allocations
Before this work, analyzer hooks converted typed arrays into newly allocated object arrays on every frame.
For example, a 1,024-point analyzer could allocate thousands of objects per second before the visualization performed its own transformation.
This PR should:
Array.from(...).map(...)in frame loops.{ index, amplitude }and{ frequency, amplitude }objects every frame.fftSizechanges.5. Reuse visualization nodes
Spectrogram
Oscilloscope
Rendering backend
Where SVG path updates remain too expensive, evaluate Canvas or OffscreenCanvas for real-time layers while retaining SVG for static axes and labels.
Acceptance criteria:
childListmutations.6. Coalesce drag interactions
Update
Input,Knob, andSliderto use a single-frame pointer scheduler.Changes include:
onChangemore than once per animation frame.onChangeEnd.Acceptance criteria:
onChangeEndreceives the final clamped value.7. Remove render-phase state updates
Remove
setStatecalls from render-time calculations anduseMemo.Affected behavior includes:
Inputsetting direction while calculating its backgroundSlidersetting direction while calculating progress stylesDirection and other derived values should be calculated as plain variables unless they represent independent user state.
Acceptance criteria:
8. Simplify render waterfalls
Reduce prop-to-state synchronization in:
CheckboxInputKnobLightSwitchVuMeterWaveformExamples:
Lightshould render directly fromoninstead of copyingoninto state.Waveformshould not copypercentagethrough an effect when it is controlled.VuMetershould derive an active index fromvaluerather than storing a complete array of binary lump values.Knobshould derive rotation from the current value without a second effect-driven render.9. Optimize VU meter rendering
The VU meter currently rebuilds arrays of 30 or 60 lump values and traverses every rendered span for each audio update.
This PR should:
Acceptance criteria:
10. Optimize Waveform rendering and interaction
dattributes during initialization.getBoundingClientRect()for every mouse move.transform: translateX(...)instead ofleft.Acceptance criteria:
11. Fix ResizeObserver scheduling
The previous throttle implementation invoked the callback immediately and then invoked it again after 100ms even when no additional resize occurred. Pending timeouts were not canceled during cleanup.
This PR should:
Affected components include:
12. Optimize Envelope drag rendering
onChangecallback per frame.13. Move waveform preprocessing off the main thread
useWaveformcurrently walks all PCM frames synchronously when a newAudioBufferis supplied.This PR should:
Acceptance criteria:
14. Deduplicate audio fetching and decoding
Improve
useFetchAudioso multiple components using the same URL do not independently download and decode identical audio.Possible implementation:
AudioBuffervalues with explicit invalidation.This behavior should remain opt-in or clearly documented if global caching would change existing lifecycle expectations.
15. Stabilize Context and class-generation work
useCallback.tailwind-mergerepeatedly inside frame-driven components when class inputs are static.Component impact
leftanimationBackward compatibility
maxFps,pauseWhenHidden, or cache configuration.Testing
Existing checks
New performance regression tests
ButtonCardusePlayerLightWaveformVuMeterInputKnobSliderBrowser validation
Test the following scenarios in a production build:
Performance acceptance criteria
childListmutations.Before/after results
Update this table with the final measurements before merging:
Buttonconsumer gzip, React excluded< 20 kBButtonconsumer gzip, React and Tone excluded< 20 kB≤ 1 pending≤ 1 pending≤ 1 pendingRisks
These areas should be covered by compatibility fixtures, browser tests, and release notes.
Documentation
Related issue
Closes #