Skip to content

Build1 publisher3 min readPublished

Subito's MultiSelect mounted 1,175 options in one commit to paint six rows

The same component that felt instant on a dozen-entry filter recorded a 1,256 ms local INP on the Shoes brand filter, and Subito fixed it with about 90 lines of virtualization keyed to one measured row height.

The Engineer · Build desk

Illustration accompanying Subito's MultiSelect mounted 1,175 options in one commit to paint six rows

What happened

  • Subito's shared MultiSelect component, built on a re-skinned react-select, carries about a dozen entries on filters like item condition or shipping, and at that size it felt instant.
  • On the Shoes category the Brand filter holds around 1,175 options, and opening it on a mobile viewport at a 4x CPU slowdown produced a local INP of 1,256 ms, rated poor.
  • Instead of adding a virtualization library, Subito wrote a roughly 90-line useVirtualScroll hook that renders only the visible rows plus a five-item overscan buffer.
  • The post reports the interaction at 96 ms after the change, measured the same way in the Chrome DevTools Performance panel.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint A shared component that takes an unbounded list argument needs a stated ceiling on option count, because the count it was reviewed at is the only count anyone has evidence for.
  • cost Subito now maintains its own virtualization code, and with it a standing layout requirement: every option row must render at the one height measured after mount.
  • decision Whether the hook lands inside the design system's MultiSelect by default or stays opt-in decides which team pays the debugging cost the next time a filter grows past a few hundred entries.
  • precedent Ninety lines of standard React APIs clearing a poor INP raises the bar a virtualization dependency has to clear before it enters the bundle.

Divide the measurement by the work. A local INP of 1,256 ms across roughly 1,175 mounted Option instances is about 1.07 ms per option, measured at a 4x CPU slowdown [6][9][2]. That unit only holds if mounting cost scales linearly with the option count, and the 1,256 ms also contains input delay and presentation delay [8]. Take it as rough anyway: a dozen options comes out near 13 ms [3]. The condition and shipping filters run the same code as the brand filter at one percent of the volume, and Subito says they felt instant [2].

The cost sits in a single synchronous commit. react-select hands MenuList the whole options array, and opening the menu builds every Option, each one a label plus a custom Checkbox generating several DOM nodes [9]. The visible window had room for about six rows, so React built roughly 1,169 components nobody could see [9][5]. That work lands in INP's processing-duration phase, and the browser cannot paint the open menu until the commit finishes [8].

The replacement is about 90 lines and it turns on one number: the height of a single row, read from the DOM right after the menu mounts [11][12]. The useLayoutEffect that reads it has an empty dependency array, so the measurement happens once per mount, from the first element with role="option", through getBoundingClientRect [12]. Everything after that is division on that number: totalHeight = totalCount * itemHeight, and startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN) [13]. With OVERSCAN set to 5 and about six visible rows, the mounted slice is roughly 16 components instead of 1,175 [11][4].

Every index in that calculation descends from one measured height, so the approach holds only while each row renders at exactly that height. A brand name long enough to wrap onto two lines puts the scroll offset and the rendered slice out of step. Subito names the other route, measuring each row one by one, and calls the equal-height assumption "by far the simpler of the two" [14].

Two things have to be true for the 96 ms to transfer to another list [16]. The window has to show a handful of rows out of a large catalog, and per-row DOM has to stay about as cheap as a label plus a checkbox, because virtualization removes instances, not the cost of each one [15]. The measurement condition matters as much: this was a local DevTools reading on a mobile viewport at a 4x CPU slowdown, which Subito describes as a standard way to approximate a mid-tier phone [5]. The comparison that makes the before-number legible is the field one, where 1,256 ms sat in the bottom 6% of real-user INP experiences [6]. Google's threshold for a good INP is 200 ms, so the reported 96 ms sits under half of it [7][6].

What to watch

  • Whether Subito publishes an INP for typing in the search field, where filtering still runs across the whole option array.
  • Whether a wrapping brand label forces the switch from one measured row height to per-row measurement.
  • Whether field INP for the Shoes category moves, not just the throttled DevTools reading.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories