Skip to content

Add performance benchmark - #35

Merged
joeblau merged 2 commits into
mainfrom
perf/benchmark
Oct 21, 2026
Merged

joeblau merged 2 commits into
mainfrom
perf/benchmark

Conversation

@joeblau

@joeblau joeblau commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Adds a reproducible performance benchmark for @bloxwap/haptic under packages/haptic/bench/, run with npm run bench from the repo root (about 11 seconds). It builds dist, then measures:

  • Bundle size: minified, gzip and brotli bytes for the full ESM and CJS entries, plus the tree-shaken cost of importing each single export. esbuild metafile attribution shows how many bytes come from web-haptics.
  • Cold import time for the ESM and CJS entries in fresh Node processes, with React alone as the baseline.
  • SSR: renderToString with 1, 100 and 1,000 targets.
  • Client mount, unmount and re-render with 100 and 1,000 instances, on the vibration and iOS native-switch paths (production React, jsdom).
  • Commits and renders per mount, via React <Profiler> and an instrumented copy of dist.
  • Per-tap cost on the vibration, iOS label/switch and desktop-without-vibration paths, plus imperative triggerHaptic().
  • Per-instance resources: DOM nodes, listeners and MutationObservers, plus engines, body nodes and rAF callbacks created by taps.
  • Cleanup and memory: leftovers after unmount, and the heap delta over 8 GC'd mount/unmount cycles of 1,000 instances.

No runtime or dev dependencies were added; it uses the existing esbuild, jsdom and React. src/ is unchanged. bench/ is not in the npm tarball (pack:check passes), and results are written to the git-ignored bench/results/latest.json. Counts are reported, not asserted, so later optimizations won't break the benchmark. See packages/haptic/bench/README.md for each metric and its caveats.

Checks run locally and passing: npm ci, npm run lint, npm run typecheck, npm run coverage (134 tests, 100%), npm run size (4,516 gzip bytes of an 8,192 budget), npm run pack:check and npm run test:matrix (React 18 and 19).

Baseline results

The run was on main at be63bab with the same benchmark code as this branch: Node v24.22.0, Linux 6.12, an Intel Xeon with 8 cores and 15.6 GiB RAM. jsdom timings are relative signals, not device latency. Synthetic clicks don't prove tactile output.

Environment: v24.22.0; linux 6.12.94+; Intel(R) Xeon(R) Processor; 8 cores; 15.6 GiB RAM; be63babaf1455b9caae3bb5c3b0097282626f06f; 2026-10-20T22:07:53.946Z
Build: production React for timings; production profiling renderer + instrumented dist for untimed counts.

| Bundle | Min bytes | Gzip bytes | Brotli bytes | web-haptics bytes¹ |
| --- | ---: | ---: | ---: | ---: |
| Full ESM | 11962 | 4514 | 3983 | 6440 |
| Full CJS | 13181 | 4873 | 4305 | 6996 |
| triggerHaptic | 9110 | 3313 | 2919 | 6440 |
| canTriggerHaptic | 962 | 491 | 410 | 0 |
| isIOSWebKitWithoutVibration | 450 | 296 | 241 | 0 |
| useIOSNativeHaptics | 506 | 333 | 272 | 0 |
| HapticTarget | 9752 | 3781 | 3341 | 6440 |
| createHapticAdapter | 696 | 376 | 322 | 0 |
| web-haptics defaultPatterns | 6620 | 2427 | 2129 | 6440 |
| web-haptics WebHaptics | 6620 | 2427 | 2132 | 6440 |

| Operation | Median | p95 | Unit | Ops/sec² |
| --- | ---: | ---: | --- | ---: |
| Cold import react | 2.436402 | 2.765815 | ms | — |
| Cold import ESM | 5.625203 | 6.788476 | ms | — |
| Cold import CJS | 4.399999 | 4.982822 | ms | — |
| SSR 1 | 0.072618 | 0.125685 | ms/render | 13770.7 |
| SSR 100 | 0.393873 | 0.752808 | ms/render | 2538.9 |
| SSR 1000 | 1.817522 | 2.531961 | ms/render | 550.2 |
| vibration client mount 100 | 7.872420 | 12.033830 | ms | — |
| vibration client unmount 100 | 3.271433 | 5.217454 | ms | — |
| vibration client mount 1000 | 37.908231 | 49.959816 | ms | — |
| vibration client unmount 1000 | 8.469512 | 11.598866 | ms | — |
| vibration re-render 100 stable | 2.776254 | 3.032485 | ms | — |
| vibration re-render 100 feedback | 2.583816 | 2.803195 | ms | — |
| vibration re-render 100 triggerOptions | 2.805953 | 2.958038 | ms | — |
| vibration re-render 100 webHapticsOptions | 2.685855 | 2.905579 | ms | — |
| vibration button click | 0.032171 | 0.046166 | ms/tap | — |
| Imperative triggerHaptic | 0.000241 | 0.000271 | ms/tap | — |
| Heap delta: 8 cycles × 1000 | -316760.000000 | — | bytes | — |
| ios client mount 100 | 15.892293 | 25.421023 | ms | — |
| ios client unmount 100 | 3.768445 | 4.173735 | ms | — |
| ios client mount 1000 | 108.490713 | 126.546416 | ms | — |
| ios client unmount 1000 | 13.710916 | 19.976748 | ms | — |
| ios re-render 100 stable | 3.676845 | 6.404628 | ms | — |
| ios re-render 100 feedback | 3.583906 | 3.882021 | ms | — |
| ios re-render 100 triggerOptions | 3.676493 | 3.925884 | ms | — |
| ios re-render 100 webHapticsOptions | 3.373799 | 5.574551 | ms | — |
| ios label/switch click | 0.095948 | 0.111931 | ms/tap | — |
| desktop button click | 0.066103 | 0.086545 | ms/tap | — |

| Path | Instances | Commits per mount | HapticTarget renders per instance |
| --- | ---: | ---: | ---: |
| vibration | 100 | 1 | 1 |
| vibration | 1000 | 1 | 1 |
| ios | 100 | 3 | 3 |
| ios | 1000 | 3 | 3 |
| desktop | 100 | 1 | 1 |
| desktop | 1000 | 1 | 1 |

| Resources (100 instances) | Vibration | iOS | Desktop without vibration |
| --- | ---: | ---: | ---: |
| DOM nodes/instance | 3 | 5 | 3 |
| Listener adds/instance³ | 2.42 | 3.42 | 2.42 |
| Observers/instance | 1 | 1 | 1 |
| First tap: body nodes / engine labels / rAF scheduled | 0 / 0 / 0 | 0 / 0 / 0 | 3 / 1 / 2 |
| 100 distinct taps: body nodes / engine labels / rAF per tap | 0 / 0 / 0 | 0 / 0 / 0 | 300 / 100 / 2 |
| Engines constructed after 100 distinct taps⁴ | 100 | 0 | 100 |
| First tap cost (instrumented ms) | 0.375664 | 0.939849 | 1.362578 |
| Remaining 99 distinct taps (instrumented ms/tap) | 0.032582 | 0.242953 | 0.337356 |
| Unmount: listener adds / removes / balance³ | 242 / 100 / 142 | 342 / 100 / 242 | 242 / 100 / 142 |
| Unmount: active observers / body nodes | 0 / 0 | 0 / 0 | 0 / 0 |
| Unmount: listeners on document / detached nodes³ | 0 / 142 | 0 / 242 | 0 / 142 |

¹ Uncompressed esbuild contribution; shared compression cannot be attributed per dependency.
² SSR only. All timings use production React, createRoot + flushSync; passive effects settle across macrotasks.
³ Includes delegated root listeners; detached listeners are collectible, not proof of a leak.
⁴ Constructor counts use a separate instrumented build; desktop label counts independently corroborate engines.
Tap timing rows are warmed batch-normalized dispatch costs. Resource tap costs include instrumentation and a simulated 60Hz rAF drain.

What stands out

  • Importing only triggerHaptic still pulls in all 6,440 bytes of web-haptics, for 3,313 gzip bytes in total. Only the preset table is used, but it shares a chunk with the WebHaptics class.
  • On desktop browsers without vibration, each tapped instance creates its own WebHaptics engine. That engine adds a hidden label and switch to <body> and runs an rAF loop: 100 distinct taps made 100 engines and 300 body nodes.
  • Every instance creates a MutationObserver, but only the iOS overlay reads its result.
  • On iOS, each HapticTarget renders 3 times per mount, versus 1 on the vibration path. Mounting 1,000 took about 108–141 ms, versus about 38 ms.
  • No leaks were found. Unmount leaves no body nodes, no active observers and no document listeners, and the heap decreased across cycles.

Follow-up optimization issues are linked from this PR.

@joeblau
joeblau merged commit 78ec2f2 into main Oct 21, 2026
5 checks passed
@joeblau
joeblau deleted the perf/benchmark branch October 21, 2026 00:25
@joeblau joeblau mentioned this pull request Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant