The Lab · Development
Binary Search vs Array.includes() on a 50,000-Row Catalog
A one-line swap took a 50,000-row lookup from 408ms to under 1ms. I measured both methods instead of guessing which to ship.
Each line jumps to its section
- Array.includes() took 408ms for 10,000 lookups against 50,000 rows, binary search took under 1ms
- At 1,000 rows the two methods tie at about 2.3ms, the gap only opens up past a few thousand
- Binary search needs a sorted array, so an unsorted catalog has to pay a one-time sort cost first
- One function swap cut a 408ms lookup loop to under 1ms with zero new dependencies
A one-line swap took a 50,000-row lookup from 408 milliseconds down to under one millisecond. I didn't want to guess at that number, so I built both versions, ran them against the same data, and timed them. Here's exactly where the time goes and when the fix is worth doing.
The Setup
I built a sorted array of 50,000 numbers, the kind of shape you get from product IDs, sorted timestamps, or any list you can order once and reuse. Then I generated a batch of lookup targets, 80% of them values that actually exist in the array and 20% that don't, so neither method gets to cheat by only testing hits.
Two functions ran against the exact same targets. The first is the one everyone reaches for without thinking: catalog.includes(target). It's built in, it reads clearly, and for small arrays it's fine. The second is a plain binary search, the kind you'd write in an interview and then forget about:
function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1; else hi = mid - 1;
}
return -1;
}
I ran both at four sizes: 1,000 rows with 2,000 lookups, 10,000 rows with 2,000 lookups, 50,000 rows with 2,000 lookups, and 50,000 rows with 10,000 lookups. Same hardware, same process, back to back, no warmup tricks either way.
I also logged the hit count for every run. Not for performance, just as a sanity check. If includes() and binary search ever disagreed on how many targets matched, I'd know one of them had a bug before I trusted a single timing number.
They never disagreed. Good, that's what you want from a sanity check.
What the Numbers Actually Showed
At 1,000 rows, includes() took 2.50ms and binary search took 2.29ms for 2,000 lookups. That's not a typo. At that size the difference is noise, and if you're searching a list that small, rewriting it isn't worth your afternoon.
The curve bends hard right after that. At 10,000 rows, includes() jumped to 18.12ms while binary search stayed at 0.30ms, a 60x gap. At 50,000 rows with the same 2,000 lookups, includes() hit 63.95ms against 0.30ms for binary search, over 212x. Push the lookup count up to 10,000 searches against those same 50,000 rows and includes() climbs to 408.21ms while binary search barely moves to 0.94ms. That's a 432x difference, and it's the kind of number that turns a snappy filter into one that feels broken.
The reason is simple once you see it laid out. Array.includes() walks the array from the front until it finds a match or runs out of room, so its cost grows in a straight line with the size of the array. Binary search throws away half the remaining space on every comparison, so doubling the array only adds one more step.
That's the whole trick. Halve, compare, halve again.
At small sizes that distinction doesn't matter enough to notice. At tens of thousands of rows it's the entire story, and the gap keeps widening the bigger the array gets. I didn't test a million rows here, but the shape of the curve says it would look even more lopsided.
Put the 408ms number next to what people actually notice. Research on perceived responsiveness has put the "feels instant" line somewhere around 100ms for a while now, and anything past that starts registering as a delay instead of a reaction. 408ms isn't a crash and it isn't a freeze, but it's long enough for a filter or search box to feel like it's thinking instead of responding. Under 1ms, there's nothing to feel at all.
I ran this on Node, not in a browser tab, so treat the absolute milliseconds as a relative comparison rather than a promise about what you'll see in Chrome or Safari. The ratio between the two methods is the part that travels: the relationship between array size and lookup cost doesn't change because the JavaScript engine changed, only the constant in front of it does.
The Catch Nobody Mentions
Binary search only works on a sorted array. That's the trade, and it's not free. If your data arrives unsorted, you pay for a sort once, and Array.sort() on 50,000 numeric items costs real milliseconds too, though far less than you'd think and nowhere near the 408ms you're trying to avoid.
The real catch shows up when the data changes often. Every insert into a sorted array means finding the right slot and shifting everything after it, which is its own O(n) cost. If you're appending new rows constantly and searching rarely, keep it simple and stick with includes(). If you sort once, because the data is already sorted, like IDs assigned in order, and then search many times, binary search wins by a wide margin every time.
There's also a readability cost that's easy to underrate. A junior dev, or you in six months, sees catalog.includes(x) and knows exactly what it does. A hand-rolled binary search needs a comment, a test, and a reason.
I wrote the comment. I also wrote a test that checks the edge cases: empty array, single item, target smaller than everything, target larger than everything.
Skip those tests and you'll ship an off-by-one that only shows up on the smallest or largest value in the set, which is exactly the kind of bug that survives a demo and fails in production three weeks later. The mid = (lo + hi) >> 1 line looks harmless until hi goes negative on an empty array and the loop never runs, silently returning "not found" for a lookup you expected to crash loudly instead.
There's a third option I almost left out: a Set. I built one from the same 50,000-row catalog and ran it against the same 10,000 lookups. It came back at 2.61ms, roughly on par with binary search and still miles ahead of includes(). A Set or a Map gives you O(1) lookup by hashing the value, so size barely matters at all.
The catch with a Set is narrower than people expect. It only answers "is this exact value present," nothing else. Binary search can also answer "what's the first row greater than or equal to this value," which matters the moment you need a range, a price bracket, or a "closest match" instead of an exact one. A Set also costs more memory, since it builds its own hash table instead of reusing the array you already have. For a pure membership check, build the Set once and don't look back. For anything involving order, binary search is still the right tool.
When This Actually Matters for a Small Shop
Most front-end code never touches an array big enough for this to matter. A dropdown with forty options, a tag list with a dozen entries, a settings page, none of that needs binary search. I'd leave all of it alone.
Where it starts to matter is anywhere you're filtering or looking things up against a catalog, a big tag index, or a dataset you loaded once and query repeatedly inside a session. If you're already sorting the data for display, alphabetical, by date, by ID, you've done the expensive part for free, and swapping the lookup function costs you ten lines and buys back a chunk of a second on every search.
I'd also flag the opposite mistake: don't reach for binary search just because a list theoretically could grow. I measured the 1,000-row case specifically because "someday this might be big" is a bad reason to add a comment-requiring function today.
Wait until the array you're searching is actually in the thousands, then make the swap. Below that, the built-in method reads better and costs you nothing you'd notice.
I'd also think about where the array comes from before picking a fix. If it's loaded fresh from an API response or a database query on every page view, sorting it once right after it arrives is nearly free, since you're already paying for the network round trip anyway. If it's rebuilt from scratch on every keystroke in a search box, none of this helps until you fix that first. Binary search can't save you from redoing the expensive part every time.
This is the same instinct behind counting every call debounce and throttle actually made instead of trusting whichever one sounded right, or running git bisect across a thousand real commits instead of trusting a hunch about when a bug started. A guess about performance is just an opinion with better formatting.
The only way to know which function to ship is to run both and look at the clock. It takes fifteen minutes, and you get an actual number instead of a vibe.
Bottom Line
Below a few thousand rows, Array.includes() and binary search cost the same amount of time a human can perceive, which is none. Past that, the gap grows fast and keeps growing, hitting 432x at 50,000 rows with 10,000 lookups in my test.
If your data is sorted once and searched often, the swap is ten lines of code and a short test file, and it's worth doing the day you notice a filter or lookup feel slow. If your data changes constantly and gets searched rarely, skip it. The sort and insert overhead will eat the gain before it pays you back.
The rule I'm using going forward: measure before the array grows, not after someone complains it's slow. A stopwatch beats a guess every single time, and it only costs you fifteen minutes to find out which function actually deserves to ship.