llselect vs the field

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.

Mass instantiation

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.

Ready.
Render one:
One row per library. Every number is for the WHOLE run - the total across all widgets built, not an average per widget.
Library Built / target Build total (all widgets, ms) DOM nodes (all, resting) Teardown total (all widgets, ms)
What the columns mean

Filtering is measured in the interaction-latency section below, not here - it is about one widget, not about building many.

Comparison chart

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.

Scratch area (widgets build here during a run, then the teardown measurement clears them)

Interaction latency

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.

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.

or one lib:
or one lib:
Ready.

"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.

What the columns mean

Single-select

Each cell is the MEDIAN of a few repeats of that ONE operation - not a sum across operations, not a single run. Filter is a type + clear round-trip, reported as the sum of its two medians. The chart below stacks the columns.

Build + measure to see the chart.

Multiple-select

Each cell is the MEDIAN of a few repeats of that ONE operation - not a sum across operations, not a single run. Filter is a type + clear round-trip, reported as the sum of its two medians. The chart below stacks the columns.

Build + measure to see the chart.

Fairness: making every library do the same work

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.

  1. Minified vs minified. Every library loads its minified build, llselect included (its UMD is run through terser), so parse and build cost are compared like for like - not one library's dev build against another's .min.js.
  2. Everyone renders all N candidates. Tom Select caps the dropdown to 50 options by default; it is forced to maxOptions: null so it builds the whole list like the others, instead of a fraction that looks fastest for free.
  3. Selected options stay in the list. Choices and Tom Select drop a chosen option from the dropdown by default, so choosing one shrinks their list and they re-render less than llselect / Select2 / Slim Select (which keep it and just re-mark it). They are set to keep it (renderSelectedChoices: 'always' / hideSelected: false) so every library re-renders the same-size list while you choose and filter.
  4. Timed to the painted frame, not the function return (open / close). Libraries reveal a 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.
  5. Debounce is measured, not hidden (filter / choose). Tom Select and Slim Select debounce the filter input. Rather than disabling the debounce (which would hide latency the user really feels), the timer watches the DOM and waits until it stops mutating, so the debounce delay is included - and a debounced render is never mis-read as 0 ms. llselect filters synchronously.
  6. Transitions and animations are disabled during a run. An injected * { 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.
  7. Widgets are measured on-screen. llselect refuses to open an off-screen trigger (a real-use correctness guard), which would read ~0; so each widget is scrolled into view first, and manual scrolling is blocked by the run overlay so a stray scroll cannot move one off-screen mid-measure.
  8. Each phase is timed on its own. Open, filter, choose, unchoose, remove-tag and close are measured separately, never in one mixed cycle, so one phase's cost never leaks into another's. Median of a few repeats, the first dropped as warm-up.
  9. Multi renders tags. llselect uses triggerDisplay: 'tags' so it draws one tag per chosen item like every competitor, not its cheaper count summary.
  10. No collapsing tags into a summary. Slim Select shows a single "{n} selected" tag once more than 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.
  11. Multi keeps the popup open on choose. Select2, Tom Select and Slim Select are configured (closeOnSelect / closeAfterSelect) so choosing does not close the dropdown and skip the open-list re-render that the others pay for.
  12. Filter keystrokes are always real changes. The query is cleared between timed keystrokes, so re-typing the same letter is never a no-op (some libraries keep the query after a selection, which would read 0).
  13. Deselect is enabled from each library's own API, not assumed. Unchoose-in-popup uses Slim Select's 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.
  14. The tag close button is opt-in. Choices and Tom Select only grow a tag x through a setting / plugin, so it is off by default (each library's own tag rendering) and forced on for all only when you tick the box.
  15. The custom renderer reaches the chosen display too - or is marked. The icon is added to list items and tags / triggers. Slim Select's v2 tag has no per-tag HTML hook, so its tag stays plain rather than being faked with a free CSS ::before; the limitation is documented, not hidden.
  16. The chart is drawn only after a run. Animating a chart between libraries polluted the next library's timing, so it is deferred to the end.
  17. Adapters are guarded. A library the adapter cannot drive on its loaded version shows - / error for that cell instead of breaking the page or silently reporting a wrong number.

Bundle size

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.

ScriptVersionMinified bytes

Method and caveats

Hands-on playground

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.