How much does sorting sprites by depth cost per frame?
At 100,000 sprites, sorting a shuffled list costs 27.0 ms per frame — 162% of a 60fps budget, before you draw anything. Sorting the same list when it is already in order costs 1.35 ms, and 3.36 ms when 1% of sprites have moved. The sort is not the problem. Rebuilding the list every frame is.
Depth-sorting 100,000 sprites costs 27.0 ms per frame if you hand the sorter a shuffled list, and 3.36 ms if you hand it the list you sorted last frame with 1% of the sprites moved. That is an 8x difference on identical data, and it comes entirely from whether you preserved the previous frame's order. At 60fps you have 16.67 ms for everything, so the first number is 62% over budget before a single draw call.
The short answer
- V8's
Array.prototype.sortis TimSort, which detects runs that are already in order and skips them. Sorted input is close to a linear scan. - Keeping one persistent sprite array and re-sorting it in place costs 3.36 ms per frame at 100,000 sprites. Rebuilding the array each frame costs 27.0 ms.
- At 10,000 sprites the same mistake costs 1.95 ms instead of 0.31 ms. Still 6.3x, but it fits in a frame, which is why nobody notices until the scene grows.
- The comparator matters far less than the input order. Both cases run the identical
(a, b) => a.depth - b.depth. - If your sprites genuinely arrive in random order every frame, you do not have a sort problem, you have a data structure problem.
What the numbers look like
Node 23.5.0, Apple M3. Each cell is the median of 5 separate runs of the script below, each of which itself reports the median of 7 passes. V8 is warmed on 200 throwaway sorts before anything is timed, which matters more than it sounds.
| Sprites | Shuffled | Already sorted | 1% moved | 10% moved |
|---|---|---|---|---|
| 1,000 | 0.145 ms | 0.010 ms | 0.019 ms | 0.058 ms |
| 10,000 | 1.951 ms | 0.189 ms | 0.312 ms | 0.760 ms |
| 100,000 | 27.039 ms | 1.347 ms | 3.362 ms | 8.697 ms |
As a share of a 60fps frame, the 100,000-sprite row reads: 162.2% shuffled, 8.1% sorted, 20.2% at 1% moved, 52.2% at 10% moved.
Why is sorted input so much faster?
TimSort scans for runs of already-ordered elements and merges them, rather than comparing every pair. A fully sorted array of 100,000 elements is one run, so the algorithm confirms the order and stops. That is why the "already sorted" column is 20x faster than the shuffled column rather than the 2x you might expect from a constant factor.
The interesting column is "1% moved". A thousand sprites changed depth, the other 99,000 did not. TimSort finds long ordered runs between the disturbances and merges a small number of them. You pay 3.36 ms instead of 1.35 ms, not 27.0 ms. Partial order is almost as good as total order.
Why does the naive version shuffle at all?
Nobody writes shuffle() in a render loop. They write this:
function render(world) {
const visible = []
for (const e of world.entities) {
if (inView(e)) visible.push(e) // ← order now depends on entity iteration
}
visible.sort((a, b) => a.depth - b.depth) // ← sorts from scratch, every frame
for (const s of visible) draw(s)
}
The array is rebuilt from scratch each frame. Its order comes from whatever order world.entities iterates in, which changes as entities spawn and die. From TimSort's point of view that is arbitrary input, and it pays the full price every frame.
The fix is to sort a list that persists:
class Renderer {
constructor() { this.sprites = [] } // lives across frames, stays sorted
render() {
this.sprites.sort((a, b) => a.depth - b.depth) // near-sorted: cheap
for (const s of this.sprites) {
if (s.visible) draw(s) // cull during the draw, not before
}
}
}
Culling moves into the draw loop. The list keeps its order between frames, so the sort sees the previous frame's arrangement with only the sprites that actually moved out of place.
Does a faster sort algorithm help?
Not as much as fixing the input. Sprite depths are usually a small set of integer layers, which invites a counting sort, and a counting sort is genuinely O(n) on that data. But it is O(n) every frame, unconditionally. TimSort on near-sorted input is doing almost no work at all. You would be replacing 3.36 ms of adaptive work with a fixed linear pass plus the allocation of the bucket array.
Reach for a counting sort when depth really is a handful of discrete layers and the list is genuinely unordered each frame — a particle system that rebuilds its buffer, for example. For anything with persistent entities, keeping the array sorted is both faster and less code.
When does this actually matter?
At 1,000 sprites, the worst case is 0.145 ms. That is 0.9% of a frame and you will never see it. The mistake is invisible at prototype scale and becomes a 162% frame overrun at 100,000, which is why it tends to surface late, when someone adds a particle layer or a tile-based background and the profiler suddenly points at sort.
The threshold on this machine is around 10,000 sprites, where the shuffled case first crosses 10% of the frame budget. Below that it is noise. Above it, the difference between rebuilding and reusing is the difference between shipping and profiling.
Check it yourself
Save as sort-bench.cjs and run node sort-bench.cjs. It takes about 20 seconds.
const N_LIST = [1000, 10000, 100000];
const byDepth = (a, b) => a.depth - b.depth;
const make = n => Array.from({length: n}, () => ({
x: Math.random()*1920, y: Math.random()*1080, depth: Math.random()*1000 }));
const clone = a => a.map(s => ({x: s.x, y: s.y, depth: s.depth}));
const shuffle = a => { for (let i=a.length-1;i>0;i--){ const j=(Math.random()*(i+1))|0;
[a[i],a[j]]=[a[j],a[i]]; } return a; };
const jitter = (a,f) => { const k=Math.max(1,Math.round(a.length*f));
for (let i=0;i<k;i++) a[(Math.random()*a.length)|0].depth = Math.random()*1000; return a; };
const median = a => { const s=[...a].sort((x,y)=>x-y); return s[s.length>>1]; };
// Warm V8 before timing anything. Without this the first case measured looks
// slower than the last, and the table comes out non-monotonic.
for (let w=0; w<200; w++) { const t = make(2000); shuffle(t).sort(byDepth); t.sort(byDepth); }
for (const n of N_LIST) {
const reps = n >= 100000 ? 8 : 30;
const base = make(n), sorted = clone(base).sort(byDepth);
const cases = {
"shuffled": () => shuffle(clone(base)),
"already sorted": () => clone(sorted),
"1% moved": () => jitter(clone(sorted), 0.01),
"10% moved": () => jitter(clone(sorted), 0.10),
};
for (const [label, build] of Object.entries(cases)) {
const passes = [];
for (let p=0; p<7; p++) {
const copies = Array.from({length: reps}, build);
const t0 = performance.now();
for (const c of copies) c.sort(byDepth);
passes.push((performance.now()-t0)/reps);
}
const ms = median(passes);
console.log(`${n}\t${label.padEnd(15)}\t${ms.toFixed(3)} ms\t${(ms/16.67*100).toFixed(1)}% of a frame`);
}
}
The warm-up loop is not decoration. The first version of this benchmark reported 1,000 sorted sprites as slower than 10,000 sorted sprites, because the earlier cases ran before V8 had optimised the comparator. Any deep-copy or sort benchmark without a warm-up phase measures the JIT, not the algorithm.
If you want the sorted-list pattern already wired into a renderer, it is how the 2D pipeline in our game engine keeps its draw order: one persistent array per layer, sorted in place, culled at draw time.