Two browser benchmarks against the native <select> (the
baseline floor), Choices.js, Select2, Tom Select and Slim Select: building
many selects at once (mass instantiation), and interacting with one
(interaction latency). Everything runs in your browser.
Lower is better; numbers depend on your machine.
A stress test: build many independent selects at once and measure it, with the widgets actually rendered in the scroll area below.
Heavy. Each library gets a time budget (about 20 s); a competitor that cannot finish in it stops early and its row shows how many it built before the cut-off. "Run all" walks the five libraries in turn, so it can take a while.
| Library | Built / target | Build total (all widgets, ms) | DOM nodes (all, resting) | Teardown total (all widgets, ms) |
|---|
240 / 10000 (timeout) - itself a result.destroy() every widget just built (the mirror of Build total).
A library that leaks listeners or unwinds slowly shows here. Measuring it
clears the scratch area, so the widgets appear during the build and are
gone after. Like Build total it is one run, and a timed-out library tears
down only what it built.Filtering is measured in the interaction-latency section below, not here - it is about one widget, not about building many.
Each metric is shown relative to its own best performer (100% = winner, longer is better), so they share one axis. Click a legend entry to show or hide that metric; hover a bar for the real value. Throughput stays fair even when a library timed out (it is a per-widget rate, not a total).
Run a scenario to see the chart.
These numbers are best-effort, not a guaranteed-fair ranking. The libraries differ too much under the hood to time identically:The harness normalises what it can (see Fairness) and times to the painted frame / settled DOM, but treat small gaps as noise. That is why the measured widgets are left live below - open, filter and select them yourself to feel the real latency, rather than trusting a single number.
- some debounce the filter input, with no setting to turn it off;
- some animate almost everything - the popup opening and closing, tags appearing and being removed - and some of that runs on a JavaScript timer that disabling CSS animation cannot reach;
- some do their work in a microtask (
queueMicrotask/ aPromise.then), others in a macrotask (setTimeout);- some paint on the next
requestAnimationFramerather than synchronously;- some phases a library's API simply does not offer (shown as
n/a).
The other axis: one dedicated widget per library. Each operation is
timed separately (never mixed), so one phase's cost never
leaks into another's - opening a big list in isolation is
where llselect builds its option DOM (it renders nothing until you open). The
widgets stay live below; click them yourself too. Native
<select> is not in the tables: its dropdown
is browser / OS-driven, so the page cannot time its open / filter / close, and
what JS can measure (setting a value) is ~0 - no discriminative value, and it
would hide the real latency you feel. Its widget is kept live below so you can
open and scroll it by hand and judge that for yourself.
"Build + measure" fills that mode's table; or click one library to (re)measure just it, leaving the rest. Stacked chart: the whole bar is the sum of the phases (shorter is better), each segment a phase; click a legend entry to show or hide it. Details of how each phase is measured are under "What the columns mean" below each table.
scaleY(0) and never removes the option DOM, so its
second open is nearly free - only its first open is honest. Timed to after
the next paint.q (narrows the list), then clear it: a round-trip
reported as the SUM of both directions, five reps. It goes through each
library's real search - including its debounce / throttle (Tom Select
~300 ms, Slim Select ~100 ms), which is latency the user really
waits through. (Focus matters: Choices ignores an unfocused input, which is
why this used to read ~0.) Timed to when the DOM settles.mouseup, not a synthetic
click - and time the toggle-off. The harness checks the chosen count actually
dropped and reads n/a if not. llselect,
Select2 and Slim Select (with
allowDeselect) deselect on click. Choices and
Tom Select read n/a: their dropdown is add-only
- clicking a chosen option just re-adds it. That does not mean they cannot
deselect; they do it elsewhere - via the tag's x (the Remove-tag column), and
Tom Select also via Backspace. (Tom would deselect from the list only with a
plugin that turns every option into a checkbox.) All verified headless.requestAnimationFrames +
a forced layout): Choices reveals a display:none list whose
real cost - laying out and painting N options - lands on the frame, not
in the synchronous call, so a plain timer reads ~0 (floor ~one to two
frames). Filter / choose are timed to when the DOM settles
so debounced renders count. Transitions / animations are disabled during
a run so a CSS transition (Slim Select) is not counted as work. Median of
a few repeats (first dropped as warm-up). Native <select>
is not in the tables (browser-driven dropdown, nothing meaningful for JS to
time) - its live widget is below to try by hand.Build + measure to see the chart.
Build + measure to see the chart.
The point of a comparison is that every library is measured doing the
same work, timed the same way, with no library flattered by an
artifact of how it renders. These are the rules the harness enforces (each one
fixes a real trap hit while building this - see docs/llm/SPEC_BENCHMARK.md
for the gory detail):
In the interaction tables above, a * in
a cell means that one number involved per-library tuning -
hover the star for a short explanation; the full per-library
list is in docs/llm/BENCHMARK-LIBS-TUNE-FOR-FAIRNESS.md.
.min.js.maxOptions: null
so it builds the whole list like the others, instead of a fraction that
looks fastest for free.renderSelectedChoices: 'always' / hideSelected: false)
so every library re-renders the same-size list while you choose and filter.display:none list on a later
requestAnimationFrame (Choices), so the real cost - laying out
and painting N options - lands on the next frame, not in the synchronous
call. The timer waits for that frame to actually paint. This is exactly the
"the benchmark says fast but I can see it hang" case: a library cannot look
instant while you are still waiting for the list to appear.* { transition: none; animation: none } means a CSS
open / close animation (Slim Select) is not counted as work - the number is
the render, not the tween.triggerDisplay: 'tags' so it draws one tag per chosen item like
every competitor, not its cheaper count summary.maxValuesShown
(default 20) items are selected - one node instead of n. It is set to
maxValuesShown: Infinity so it renders one tag per selected
item like the others; otherwise its multi DOM-node count and its tag / choose
work would collapse to near-nothing past 20 selections.closeOnSelect /
closeAfterSelect) so choosing does not close the dropdown and
skip the open-list re-render that the others pay for.allowDeselect; where a
library genuinely cannot deselect from the open list (Choices, Tom Select)
the phase is n/a, detected by checking the chosen count actually
dropped - never timed as if a no-op click worked.::before; the limitation is documented,
not hidden.- / error for that cell instead of
breaking the page or silently reporting a wrong number.Minified transfer bytes, uncompressed - the same method for every
script: each cell fetches the library's minified UMD build live (competitors
from the CDN, llselect its own dist/index.umd.js - the very
script this page runs) and reports the byte length; gzip shrinks them all by
roughly 3x. llselect's number is the complete usable library: the built-in
English UI strings are inside it, and the optional
dist/i18n.umd.js (every other language pack) is neither
required nor counted. Select2 also requires jQuery, listed separately.
| Script | Version | Minified bytes |
|---|
textContent = option.text with no per-tag HTML hook, so the
icon reaches its dropdown options but its tags stay plain text. A CSS
::before could fake the look but that is free compositor work,
not the per-tag rendering the others do, so it is deliberately not used -
Slim Select simply does less tag work here, which its Choose number
reflects. This benches a plain filterable select otherwise; tagging, remote
data and templating are out of scope.Not a benchmark: nothing is measured here. Each library just builds one live single-select and one live multi-select with n candidates, so you can judge the feel yourself - open, filter, choose, scroll - and reproduce environment-specific issues (pinch-zoom alignment, IME, screen readers) against every library at once.