Top 10 of 1M items in Node: sort is 88x slower than a heap
By Nihar Ranjan Das · Thu Oct 22 2026 · 13 min read · 0 views
View as a Web StorySoftware#performance#node.js#javascript#postgresql#algorithms

Sorting one million objects just to show the top 10 took 328 ms in Node.js 22, while a size-10 min-heap found the same ten in 3.7 ms. That is 88 times faster, and the gap grows with the list. The fastest answer of all was not in JavaScript: a Postgres index returned the top 10 of 1 million rows in 0.12 ms.
This post gives the working code for each method, the measurements, the point where the heap stops winning, and a rule for choosing between them. If you build a leaderboard, a trending list, or a "top products" endpoint, one of these numbers is probably in your request path.
What is a top-K problem?
A top-K problem is the task of finding the K largest or smallest items in a collection without caring about the order of the rest. A heap is a tree stored in an array where each parent is smaller (a min-heap) or larger (a max-heap) than its children, so the smallest or largest item is always at the root.
Web apps meet this problem constantly. A game shows the top 10 scores. A news site shows the 5 most-read posts today. An admin page shows the 20 slowest endpoints from a log. In each case the code has a big list and wants a few items from one end.
The common solution is list.sort(...).slice(0, k). It is correct and short, which is why it ships. It is also an O(n log n) answer to an O(n log k) question.
V8, the engine behind Node.js, sorts with TimSort, which is stable and fast on partly ordered data, as the V8 team's write-up on array sorting explains. TimSort is a good general sort. It still has to order all one million elements to give you ten, and that is the work you can skip.
MDN's Array.prototype.toSorted reference documents the copying version added in recent engines. It avoids mutating your array, but it does the same sort and allocates the same memory. We tested it to find out whether the newer API changes the cost. It does not.
Why does the difference matter in production?
Nobody notices 328 milliseconds in a script that runs once, but a web server that performs this calculation inside a request handler is paying that price for every visitor, and because Node.js executes JavaScript on a single thread, the other requests that arrive during those milliseconds simply wait for the sorting to finish. The practical consequence is that an endpoint which looked perfectly healthy with a thousand rows during development can quietly become the slowest and most unpredictable part of an application once the table grows into the millions, and the symptom usually appears as intermittent timeouts rather than as an obvious error in the logs.
How we measured it
The test setup and methodology are below, so you can reproduce them.
The test setup was Node.js 22 on a Mac laptop, running one process with no other load. Each item was an object { id, score } with a pseudo-random integer score below one billion, from a seeded generator so every method saw identical data. We built arrays of 10 thousand, 100 thousand, 1 million and 5 million objects.
Advertisement
We timed five methods and reported the median of 7 runs, or 3 runs at 5 million items:
sort: copy withslice(), sort descending, take the first K.toSorted: the same through the non-mutating API.- Heap: a hand-written min-heap holding the best K seen so far. It replaces the root when a larger item arrives.
- Insertion: keep a small sorted array of K and insert each item that beats the current worst.
- Quickselect: partition a copy so the K largest sit at the front, then sort only those K.
Every method returned the same ten ids as the full sort before we timed it. A wrong fast answer is not a result.
Results: how much faster is a heap than sort?
The table shows K = 10 at four list sizes. Times are milliseconds.
| Items | sort |
toSorted |
Heap | Insertion | Quickselect |
|---|---|---|---|---|---|
| 10,000 | 1.53 | 1.54 | 0.27 | 0.03 | 0.31 |
| 100,000 | 20.23 | 21.50 | 0.28 | 0.25 | 0.66 |
| 1,000,000 | 327.72 | 321.30 | 3.73 | 2.85 | 10.21 |
| 5,000,000 | 2,254.33 | 2,329.24 | 13.12 | 12.40 | 24.97 |

Three things stand out.
First, sort grows faster than the list. A 10-times-larger array cost about 13 to 16 times more. The heap and insertion methods grew roughly in step with the list, because each item is looked at once and rejected in one comparison.
Second, toSorted is no better than copy-and-sort. The new API is cleaner. It is not cheaper.
Third, at 10,000 items nothing matters. Sorting 10,000 objects took 1.5 ms. If your list is that small, keep the one-line sort and move on. The heap is for lists that are large, or for lists you touch on every request.
A reasonable engineer might wonder whether the improvement is simply an artifact of how modern JavaScript engines optimize small loops, and the evidence suggests otherwise, because the difference comes from the amount of work the algorithm performs rather than from any particular implementation detail: a sort must establish the relative position of every single element, including the nine hundred thousand objects that will be discarded immediately afterwards, while a heap only ever needs to know whether a new element beats the weakest of the current candidates.
Does K change the answer?
Yes. This is the part the textbooks skip. We fixed the list at 1 million objects and raised K.
| K | sort |
Heap | Insertion | Quickselect |
|---|---|---|---|---|
| 10 | 316.03 | 2.66 | 2.42 | 7.41 |
| 100 | 305.78 | 3.05 | 2.68 | 8.50 |
| 1,000 | 325.84 | 5.14 | 8.41 | 8.26 |
| 10,000 | 298.59 | 13.08 | n/a | 9.32 |
| 100,000 | 316.17 | 105.19 | n/a | 30.86 |

Sort stays flat near 300 to 330 ms, because it orders everything whatever K is. The heap does not stay flat. Each replacement costs about log K comparisons, and a bigger K means more replacements, so the heap rose from 2.7 ms to 105 ms.
Quickselect barely moved until K reached 100,000, where it took 31 ms. It wins once K passes about 1,000 to 10,000 in our run. At K = 1,000 they were close, at 5.1 ms for the heap and 8.3 ms for quickselect. At K = 10,000 quickselect took 9.3 ms and the heap took 13.1 ms.
Insertion into a sorted array was the fastest method at K = 10 and K = 100, and slowest of the three by K = 1,000, because each insert shifts up to K elements. It is the right tool only for tiny K.
Quickselect is a selection algorithm that partitions an array around a pivot and recurses into only one side, which finds the K-th largest item in average linear time. It rearranges the array, so we ran it on a copy.
A working heap you can paste
Here is the heap we timed, trimmed for a request handler. It returns the top K by score, largest first.
// Top K items by score, O(n log k) time and O(k) extra memory.
export function topK(items, k, score = (x) => x.score) {
const heap = []; // min-heap: heap[0] is the weakest of the best k
const swap = (a, b) => ([heap[a], heap[b]] = [heap[b], heap[a]]);
const up = (i) => {
while (i > 0) {
const p = (i - 1) >> 1;
if (score(heap[p]) <= score(heap[i])) break;
swap(p, i); i = p;
}
};
const down = (i) => {
for (;;) {
let m = i; const l = 2 * i + 1, r = l + 1;
if (l < heap.length && score(heap[l]) < score(heap[m])) m = l;
if (r < heap.length && score(heap[r]) < score(heap[m])) m = r;
if (m === i) break;
swap(m, i); i = m;
}
};
for (const x of items) {
if (heap.length < k) { heap.push(x); up(heap.length - 1); }
else if (score(x) > score(heap[0])) { heap[0] = x; down(0); }
}
return heap.sort((a, b) => score(b) - score(a));
}
Use it like this:
const top = topK(events, 10); // by .score
const slow = topK(logs, 20, (l) => l.latencyMs); // custom key
Two details make it fast. Most items fail the single comparison against heap[0] and cost almost nothing. And the heap never holds more than K items, so memory stays flat. The sort versions allocated a full copy of the array each time, which is 8 MB of pointers at 1 million items before any object data.
Why is Postgres faster than all of them?
Because the database never loads the rows into your process. We built a 1-million-row events table with an integer score and timed four ways to get the top 10 on PostgreSQL 18.1.
| Method | Median time |
|---|---|
| Fetch every row, sort in Node | 688 ms |
ORDER BY score DESC LIMIT 10, no index |
24 ms |
ORDER BY score DESC LIMIT 1000, no index |
25 ms |
ORDER BY score DESC LIMIT 10, index on score DESC |
0.12 ms |
ORDER BY score DESC LIMIT 1000, index on score DESC |
0.45 ms |

The no-index plan is a heap in disguise. EXPLAIN ANALYZE showed Sort Method: top-N heapsort Memory: 25kB on each parallel worker and a Gather Merge on top, with an execution time of 31.5 ms. Postgres keeps a 10-row heap, exactly as our JavaScript does, and scans the table once in parallel.
The indexed plan did not scan at all. It showed Index Scan using events_score_idx and an execution time of 0.006 ms inside the server, because the index already stores the scores in order and the first ten entries are the answer. The 0.12 ms we measured includes the round trip from Node.
The PostgreSQL documentation on indexes and ORDER BY describes how an index can return rows in sorted order, and the LIMIT and OFFSET page explains why LIMIT without ORDER BY gives unpredictable rows. Always sort before you limit.
The price is on writes. The index was 21 MB beside a 73 MB table. Inserting 50,000 rows took 156 ms without it and 236 ms with it, about 51% slower (our lab, 2026). That is a good trade for a hot read path and a poor one for a write-heavy log table nobody queries by score.
If you page through ranked results rather than taking the top few, the same index idea applies, and we measured the paging side in Postgres OFFSET vs keyset: page 100,000 is 6,000x slower.
It is also worth remembering that database indexes are not free, and that the decision to create one deserves the same scrutiny as any other change to a production schema, since an additional index consumes disk space, slows down every insert and update, and has to be maintained for as long as the table exists, which means that it makes the most sense for columns that are queried frequently in exactly the order that the index provides.
What about Redis sorted sets?
A Redis sorted set keeps members ordered by score as you add them, so the top K is a range read. The Redis ZRANGE reference documents the REV option for reading from the highest score down. A leaderboard that changes constantly and is read constantly suits it well.
We did not benchmark it in this post. The honest comparison is a different test, because Redis moves the cost to every write and adds a network hop and a second system to run. Reach for it when updates arrive faster than you can afford to re-query, not as a first step.
What breaks if you get it wrong?
Most of the problems we see in real code reviews are not exotic algorithmic mistakes but ordinary, understandable oversights that only become visible under production traffic, which is why the following list is worth reading before you copy any of the snippets above into an application that other people depend on.
- Mutating the source array. Plain
sortreorders your array in place. If the same array feeds other code, you change their results. Copy first, or use a heap, which never touches the input. - Comparators that return booleans.
(a, b) => a.score < b.scorereturnstrueorfalse, not a number. It appears to work in some engines and silently misorders in others. Returnb.score - a.score. - Subtracting large values.
b.score - a.scoreis safe for scores below about 2^53. For values that can overflow or beNaN, use explicit<and>comparisons and decide whereNaNgoes. - Sorting in the request path of a hot endpoint. One 328 ms sort blocks the Node.js event loop for every other request. At 10 requests per second that is 3.3 seconds of blocking each second, so the process falls behind. The heap's 3.7 ms does not.
The fourth is the one that causes outages, not just slowness. Node.js runs your JavaScript on a single thread, and a long synchronous sort stalls everything, including health checks.
Whichever approach you choose, the most valuable habit is to measure before deciding, because performance advice that is correct in general can still be wrong for a particular application, and the cost of a short experiment is almost always far smaller than the cost of rewriting a working endpoint on the strength of a benchmark that was run on somebody else's hardware and somebody else's data.
Which method should you use?
Choose by list size, K, and where the data lives.
- The data is in Postgres. Use
ORDER BY ... LIMITand add an index on the sort column if the query runs often. This was the fastest at 0.12 ms and needs no algorithm code. - The data is already in memory, K is small. Use the heap. It scored 3.7 ms for the top 10 of 1 million, and it is 25 lines.
- K is above a few thousand. Use quickselect on a copy, then sort the K you keep. It stayed under 31 ms even at K = 100,000.
- The list has under 10,000 items. Use
sort. It took 1.5 ms, and clarity is worth more than the saved microseconds. - Updates are constant and reads are constant. Consider a Redis sorted set, and measure it on your own traffic first.
If your top-K list also needs a cache that evicts old entries, the next structure to check is the one we measured in Node LRU cache: the Map trick is 25x slower than lru-cache.
How should you test this on your own data?
Before changing anything in a working application, it is worth spending an afternoon on a measurement that uses your own records rather than ours, because the shape of real data, the number of ties, and the amount of memory pressure on your server can all move the crossover points that we reported in the tables above. The simplest approach is to export a representative sample of the list that your endpoint actually sorts, load it into a small script, run each method several times, and compare the medians instead of the single fastest or slowest result, since garbage collection and background processes will otherwise make any individual run misleading.
What this lab did not cover
Our numbers have limits, and you should know them.
The items were plain objects with one numeric key, in a seeded random order. Real data is often partly sorted, which helps TimSort, so sort will look better on logs that arrive in time order. Ties were rare. We did not measure memory use or garbage-collection pauses directly, only elapsed time. We ran on one laptop, so the absolute milliseconds will differ on a cloud instance, while the ratios and the shape of the K curve are the part to trust.
We also did not test Postgres under concurrent load, or with a WHERE clause that makes the index unusable. A filter on a second column can force a scan even when a score index exists, and that needs its own query plan check.
The bottom line: stop sorting what you will throw away
Sorting a million objects to show ten of them cost 328 ms and blocked the event loop. A 25-line heap did it in 3.7 ms, and a Postgres index did it in 0.12 ms. The heap stops winning near K = 10,000, where quickselect takes over.
Measure the size of your list and the size of K. If the data lives in Postgres, add ORDER BY and LIMIT, then check the plan with EXPLAIN ANALYZE. If it lives in memory, paste the heap, and keep sort for lists small enough that nobody will notice.
Advertisement
FAQ
What is the fastest way to get the top 10 items from a large array in JavaScript?
A size-10 min-heap or a small insertion array. In our lab on 1 million objects, the heap took 3.7 ms and insertion took 2.9 ms, against 328 ms for sorting the whole array. Both scan the list once and reject most items with a single comparison.
Is Array.prototype.sort slow for large arrays?
It is correct, but it does more work than a top-K needs. V8 uses TimSort, and sorting 1 million objects took 328 ms in Node.js 22, rising to 2,254 ms at 5 million. The cost does not depend on how many items you keep, so the top 10 costs as much as the top 100,000.
When should I use quickselect instead of a heap?
When K is large. In our test the heap and quickselect were close at K of 1,000, at 5.1 ms and 8.3 ms. At K of 10,000 quickselect took 9.3 ms against 13.1 ms for the heap, and at K of 100,000 it took 31 ms against 105 ms. Quickselect reorders its input, so run it on a copy.
How do I get the top N rows in Postgres quickly?
Use ORDER BY score DESC LIMIT N and add an index on score DESC if the query is frequent. On 1 million rows it ran in 24 ms without an index, using a top-N heapsort, and in 0.12 ms with one. The index added 21 MB and made inserts about 51 percent slower in our test.
Does toSorted perform better than sort?
No. In our run toSorted took 321 ms against 328 ms for copy-and-sort at 1 million objects, which is within noise. It avoids mutating your array, which is a correctness benefit, but it performs the same full sort and allocates the same copy.
Comments
Loading…
Sign in to join the conversation.
Related posts

Idempotency keys: check-then-insert double-charged 216 times
The most common idempotency design, "look up the key, and if it is missing do the work and store it", double-charged 216 times in our lab when 1,000 payments were each retried five times at once. A
Thu Oct 22 2026 · 12 min read · 0 views

Node LRU cache: the Map trick is 25x slower than lru-cache
The popular "Map delete-and-set" LRU cache is about 25x slower than the lru-cache package once the cache holds 10,000 keys. In our Node.js benchmark it managed 0.59 million operations per second,
Wed Oct 21 2026 · 10 min read · 0 views

Consistent hashing: 100 virtual nodes still run 18% hot
A hash ring with 100 virtual nodes per server still leaves the busiest of 10 servers about 18% above the average load. In our lab it took 1,000 virtual nodes to bring that down to 5%. Rendezvous
Wed Oct 21 2026 · 11 min read · 0 views