惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
S
SegmentFault 最新的问题
N
Netflix TechBlog - Medium
Vercel News
Vercel News
F
Fortinet All Blogs
量子位
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
MongoDB | Blog
MongoDB | Blog
Y
Y Combinator Blog
GbyAI
GbyAI
博客园 - 三生石上(FineUI控件)
Apple Machine Learning Research
Apple Machine Learning Research
爱范儿
爱范儿
月光博客
月光博客
Recent Announcements
Recent Announcements
人人都是产品经理
人人都是产品经理
Hugging Face - Blog
Hugging Face - Blog
D
DataBreaches.Net
H
Help Net Security
阮一峰的网络日志
阮一峰的网络日志
D
Docker
WordPress大学
WordPress大学

Echo JS

GitHub - aboviq/supapower: A sync engine for Supabase and a local PGlite instance - inspired by PowerSync. billboard.js 4.1.0: Live resizing, configurable subchart, React subpath & CSP-safe worker GitHub - evoluteur/cymatics: Play a frequency and watch the sand settle into its Chladni figure, computed from the wave equation. Memdeklaro - The Basics of Decentralized Identity (DID) and Self-Sovereign Identity (SSI) How Railmid Works GitHub - evoluteur/platonic-solids: Turn the five Platonic solids in 3D, show their duals, read their measurements, and print the nets to fold your own. Sharing Application State in a URL GitHub - evoluteur/sacred-geometry: Sacred Geometry Generator: draw, tune, and export Vesica Piscis, Seed of Life, Flower of Life, Metatron's Cube, and the Golden Spiral as SVG Best of Self-Sovereign Identity: Digitalcourage, World Passport and Memdeklaro Reads Are Subscriptions - Migrating from Zustand to Coaction GitHub - evoluteur/binaural-beats: Simple web page to play binaural beats for sleep, meditation, relaxation, and focus: Delta, Theta, Alpha, Beta, and Gamma brainwave frequencies, with an optional pink or brown noise bed. toast-queue — Accessible, customizable toast notifications Building a High-Performance Data Grid in React, Vue, and Svelte I built a flight recorder for AI sessions React Authentication With JWT, Zustand, and Axios | JavaScript Tools Blog My idempotency library had one job. A dropped connection made it run the payment twice. "half-open" twice is not the same state: the bug that shaped breakwater 1.0 GitHub - evoluteur/evolutility-server-node: Framework for building REST APIs for CRUD with models rather than code (using Node.js, Express, and PostgreSQL). React Router v8 in Action: Lazy Loading and Nested Routes One $ for every environment | Xec My test suite had 100% coverage. Mutation testing still found real bugs. The type-safe data layer for Kysely | Kysera What JavaScript Obfuscation in the AI Era | JavaScript Tools Blog Using Mongoose Studio with Apache Cassandra via Data API GitHub - trekhleb/yesbrainer: 🧠 A council of AI models for the decisions that aren't no-brainers — they answer in parallel, debate to consensus, or get judged to a verdict. Browser-only, open source, bring your own keys (BYOK), no backend. Node.js has plenty of circuit breakers. So why did I build another one? My Redis library said the write succeeded. Redis was down. GitHub - Techthos/gadget: Prebuilt, interactive HTML widgets for MCP Apps in Go — data tables and forms, self-contained in a single binary, host-themed, spec-compliant. GitHub - evoluteur/react-morph-charts: React component for bubble chart, bar chart, and pie chart, with animated morphing transitions between charts, on hover, and on window resize.
From 1,256ms to 96ms: Fixing INP in a Massive React Dropdown
Giovanni Tramutola · 2026-09-15 · via Echo JS

The Scenario

In Subito's design system, we rely heavily on a custom MultiSelect component. We use it across our marketplace as a standard checkbox-style filter: you search, tick a few boxes, and hit Apply.
Under the hood, it's built on top of react-select, completely re-skinned.

For most filters (like item condition or shipping), the option list has about a dozen entries. It felt instant because a dozen rows are nothing for React or the browser.

But then it was used for the "Marca" (Brand) filter. On a live page like our Shoes category, this filter has a catalog of around 1,175 options.

The Problem

Screen recording on a mobile viewport with 4x CPU throttling: tapping the

Suddenly, on a mobile device with a throttled CPU, opening that dropdown stopped being instant. It became one of the worst interactions on the entire page.

To diagnose it, we recorded a session with Chrome DevTools' Performance panel open, simulating a mobile viewport with a 4x CPU slowdown (a standard way to approximate a mid-tier phone).

When the user clicked to open the "Marca" dropdown, the live INP (Interaction to Next Paint) metric exploded:

Local INP: 1,256 ms, rated "poor" and in the bottom 6% of real-user INP experiences.

The interaction sat frozen for well over a second before the browser could paint the open menu. With Google's threshold for a "good" INP sitting at ≤200ms, this was completely unacceptable.

Why It Happened

INP measures interaction latency across three phases: input delayprocessing durationpresentation delay.

In our case, the processing duration was a massive synchronous block of work preventing the repaint.

We tracked the root cause down to the mounting cost of opening the menu.
Our MenuList component received the entire array of 1,175 brands. Opening the menu forced React to simultaneously create ~1,175 Option component instances (each containing a label and a custom Checkbox, generating several DOM nodes).
All this happened in a single synchronous commit, even though the visible window only had space to show about 6 rows at a time.

How We Solved It

Screen recording of the same interaction after the fix: the menu opens immediately and the Local INP value in Chrome DevTools stays green, in the

We didn't rewrite everything from scratch, nor did we import massive third-party virtualization libraries. We attacked the exact root cause using standard React APIs.

We could keep the solution this small thanks to one specific property of our option list, which we'll come back to right after the code.

What is virtualization?

Instead of rendering every item in a list, virtualization renders only the items currently visible on screen, plus a small buffer just outside the viewport. Our "Marca" filter has around 1,175 brands, but the dropdown only shows a handful of rows at a time.

Without virtualization:

1,175 brands
┌──────────────────────────────┐
│ Brand 1                      │
│ Brand 2                      │
│ Brand 3                      │
│ ...                          │
│ Brand 1,175                  │
└──────────────────────────────┘

All 1,175 components are mounted

With virtualization:

1,175 brands
┌──────────────────────────────┐
│                              │
│     Brand 42                 │
│     Brand 43                 │
│     Brand 44                 │
│     Brand 45                 │
│     Brand 46                 │
│     Brand 47                 │
│     Brand 48                 │
│                              │
└──────────────────────────────┘

Only the visible items + a small buffer
are mounted in the DOM

We don't remove anything from the list itself. The full list is still there for scrolling and searching. We just avoid creating React components and DOM nodes for items the user can't currently see, and as the user scrolls, the visible window moves while React mounts the new rows and unmounts the ones no longer needed.

This is particularly effective for our MultiSelect: the user may have 1,175 brands available, but at any given moment they can only see around 6 rows.

There are two ways to do it: assume every row is the same height, or measure each row one by one. What follows is the first one, which is by far the simpler of the two.

Virtualizing the list to fix the Mounting Cost

To fix the opening delay, we hand-rolled a small useVirtualScroll hook (about 90 lines).

The concept is simple: track the scroll position and render only the visible rows, plus a small buffer (OVERSCAN of 5 items) to prevent blank flashes during fast scrolling.

It all starts from a single number: the height of one row, read from the DOM right after the menu mounts.

// Measure the actual rendered option height once after mount
useLayoutEffect(() => {
  const el = listboxRef.current?.querySelector<HTMLElement>('[role="option"]');

  if (el) {
    const h = el.getBoundingClientRect().height;
    if (h) setItemHeight(h);
  }
}, []);

Everything else is arithmetic on that one number:

// How tall the list would be, and which slice of it to render
const totalHeight = totalCount * itemHeight;

const startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);

const endIdx = Math.min(
  totalCount,
  Math.ceil((scrollTop + maxHeight) / itemHeight) + OVERSCAN,
);

const offsetTop = startIdx * itemHeight;

The rendered structure became a tall, empty container div (1,175 rows × 40px = 47,000px high, so the native scrollbar reflects the full list length) containing an absolutely positioned inner div with only the ~13 necessary rows actually mounted in the DOM.

Live demo & code: we put together a CodePen demo showing a simplified version of this windowed rendering approach. The complete implementation is in our GitHub repository.

The catch: every row must be the same height

Look at what those four values have in common: itemHeight. We measure one row and assume the other 1,174 are identical; the hook never asks how tall row 800 actually is.

When that's not true, the list breaks quietly. A row taller than itemHeight pushes everything below it out of place, and the error adds up row after row: the further you scroll, the more the content drifts away from where the scrollbar says it is, leaving gaps, overlapping rows, or options you simply can't reach.

The usual suspects:

  • Group headers, styled bigger than a normal option row.
  • Labels that wrap: "Alexander McQueen" is one line on a wide screen and two on a narrow phone.
  • Rows that change after we measured them: a different density after a rotation or resize, or a web font landing late. We measure only once, when the menu opens.

So before copying this hook, check your own list. Open it while it's still un-virtualized, at the narrowest width you support, and count how many different row heights you get:

new Set(
  [...document.querySelectorAll('[role="option"]')].map(
    (el) => el.getBoundingClientRect().height,
  ),
);

// Set(1) { 40 }         → one height everywhere: this technique works
// Set(3) { 40, 64, 88 } → it doesn't, keep reading

Our rows pass that test because the component is built to keep them uniform: long brand names are truncated with an ellipsis instead of wrapping, and group headers aren't special; they're normal rows with a checkbox, with their children simply indented. That's a design decision as much as a technical one, and it's what buys us a 90-line hook.

If your rows aren't uniform

Two honest options:

  1. Make them uniform. One row height, truncation instead of wrapping. Usually the cheapest fix, but it's a design change: bring your designer into the conversation.
  2. Use a library. TanStack Virtual and react-virtuoso measure every row and keep track of where each one ends up. That's a lot more code than what you've seen here, plus edge cases like a row being re-measured while you scroll past it. If your rows genuinely vary, this is where a dependency earns its bytes.

The point isn't "don't hand-roll virtualization". It's that our hook is 90 lines because all our rows are the same height, not because virtualization is a 90-line problem.

The Results

Metric Before After (Fixed)
Local INP (Opening) 1,256 ms ("poor") 96 ms ("good")
DOM Nodes [role="option"] up to ~1,175 13

By addressing the mounting cost, we brought a completely broken interaction back under the 100ms mark, dropping the rendered DOM nodes from over a thousand to just 13.

(Note: There is an accessibility trade-off. Virtualization physically removes off-screen items from the DOM, meaning the browser's native search (Ctrl+F) won't find brands that aren't currently visible in the window. Users have to rely on our custom search bar).

A Practical Checklist

If you're staring at a slow interaction on a massive list:

  • Check what mounts: if a menu mounts 1,000 components when it only shows 6, you've found your first suspect.
  • Check that your rows are all the same height: if they are, this technique is ~90 lines. If they're not, either make them uniform or use a library that measures each row.
  • Render only the visible rows: track scroll position and mount just the rows in view, plus a small buffer, instead of the whole list.
  • Watch the DOM node count, not just the item count: a list can hold thousands of entries as long as only a handful are ever mounted at once.

A list of 1,100 items is not inherently a performance problem. Rendering all of it, every single time, before the browser is allowed to draw the next frame... that is the problem.