Performance

Staying fast at 10,000 clips: the local-performance playbook

A cloud app scales by adding machines. A local app has exactly one — yours. So “make it fast” isn't a phase-two optimization; it's a design constraint from the first line. Here's how MediaFind keeps a big library feeling instant — and how it uses every core of that one machine when there's real work to do.

When your backend is the user's laptop, you can't paper over a slow algorithm with a bigger instance. Every inefficiency is felt directly, on a machine that's also running their browser, their music, and forty other tabs. So MediaFind's performance work is mostly about complexity, not horsepower: making sure the cost of an operation grows slowly as the library grows. A handful of patterns do most of the work.

1 · Search that doesn't read the whole library: ANN

Semantic search compares your query vector against every clip's vector. Done exactly, that's a linear scan — fine at 500 segments, sluggish at 50,000. So beyond a threshold MediaFind switches to an approximate nearest-neighbor index (hnswlib, an HNSW graph). Instead of checking every vector, it walks a small-world graph and visits a tiny fraction of them, trading a sliver of recall for sublinear query time. In practice the top results are identical; the wall-clock isn't.

2 · Rebuilding the index without ever serving a half-built one

An ANN index isn't incrementally perfect — after enough churn it's worth rebuilding. The danger is the rebuild window: if search reads the index while it's being repopulated, you get missing or wrong results. The fix is an atomic swap. Build the new index off to the side, fully, then flip a single pointer. Readers either see the entire old index or the entire new one — never a partial state.

incoming queries LIVE index v1 serves every query, uninterrupted building index v2 off to the side · not visible atomic swap flip one pointer v2 becomes LIVE readers see all-of-v1 or all-of-v2 — never a partial index
The rebuild happens beside the live index, not inside it. A single pointer flip makes the new index visible at once, so a search is never served mid-rebuild.

3 · Killing the N+1: one query, not a thousand

The home screen shows status for every item in the library. The naïve version asks the database once per item — a thousand items, a thousand round-trips, a screen that hitches as it grows. Classic N+1. The fix is unglamorous and enormously effective: fetch all of it in a single batched query and stitch it together in memory. The page load goes from “linear in library size” to “basically flat.”

4 · Cache the expensive sidebar: facets

The category and people facets — the counts next to each filter — are aggregations over the whole library. Recomputing them on every page view is wasteful, because they barely change between indexing runs. So they're cached and invalidated only when the underlying data actually moves. The common case (you're browsing, not indexing) pays nothing.

5 · Incremental, not quadratic: assigning new faces

Grouping faces into people is clustering, and clustering is tempting to redo from scratch each time. But re-clustering every face whenever one new video arrives is O(n²) — it gets quadratically slower as your people library grows, which is exactly backwards. So new faces are assigned to existing clusters incrementally (nearest existing person, or a new one if nothing's close) instead of reshuffling the whole set. Adding a video costs work proportional to that video, not to your entire history.

The occasional full rebuild — when you explicitly ask to regroup everyone — used to build a dense all-pairs similarity matrix: O(n²) memory that simply runs out of room once a face library gets big. So past the point where that matrix stops fitting, MediaFind borrows the same trick as search. Instead of comparing every face to every other, it asks the ANN graph for each face's near neighbors and union-finds those links into groups — connected components, not a quadratic matrix. The rebuild goes from quadratic to roughly linear, so even a large library regroups on demand instead of running out of memory.

A scaling bug that only bites when frozen: the ANN library has to be bundled into the packaged app, or search silently falls back to the linear path and chokes past ~10k segments — looking like a mysterious slowdown rather than a missing dependency. It's the same “silently degraded in the frozen app” trap that haunts the ML stack; that whole bug class gets its own post.

6 · Get heavy work off the request path

Some operations are just expensive — re-categorizing the whole library, rebuilding an index. Those don't belong in the milliseconds between a click and a repaint, so they run as background jobs with progress, leaving the UI responsive. (The job queue itself is covered in the architecture deep dive.)

7 · Use every core: parallel indexing

Everything above keeps search fast. But the first thing a big library does is make you wait while it's indexed — each file transcribed, frame-sampled, embedded. That work is embarrassingly parallel across files, yet the simple version does one file at a time, so a machine with a dozen cores spends most of indexing with all but one of them idle. The fix is a bounded prefetch pool: while the current file's results are being written to the database, the next few files are already being transcribed and CLIP-encoded on the other cores. The heavy, database-free stages overlap the commit instead of queuing behind it.

one at a time file 1 · decode·ASR·CLIP write file 2 · decode·ASR·CLIP write → other cores idle prefetch pool file 1 · decode·ASR·CLIP write write → finishes sooner file 2 · on other cores file 3 · on other cores
Serial indexing leaves most cores idle between stages. The prefetch pool runs the upcoming files' decode, transcribe and encode on the other cores while the current file is written — so the whole folder finishes sooner, without any of it leaving the machine.

Two details keep that honest on a machine that's also yours to use. The pool auto-tunes its width from the core count instead of fanning out to infinity, and the per-file frame workers shrink as more files run at once — so the two levels of parallelism don't stack up into far more threads than the machine has cores. The goal isn't maximum threads; it's keeping total work near the cores you actually have. Oversubscribe and everything slows down together — the same lesson the request-path work teaches, applied to the write path.

In practice: on Apple silicon, indexing runs faster than real time — you're not sitting through your footage to make it searchable — and the prefetch pool keeps the cores working between stages instead of letting them idle one file at a time. All of it on-device: no queue, no upload, no meter running.

The playbook, at a glance

OperationNaïve costWhat MediaFind does
Semantic searchO(n) scanANN graph — sublinear
Index rebuildReaders see partial stateBuild aside, atomic swap
Home statusN+1 queriesOne batched query
Facet countsRecompute every viewCache + invalidate
Add facesO(n²) re-clusterIncremental assignment
Rebuild all peopleO(n²) dense matrixANN neighbors + union-find
Re-categorizeBlocks the UIBackground job
Index a folderOne file at a timeParallel prefetch pool

The throughline: respect the one machine

There's no autoscaler coming to save a local app. That constraint is freeing, in a way — it forces honest algorithms. Pick data structures whose cost grows slowly, never serve a half-built result, and push anything heavy into the background. Do that, and a ten-thousand-clip library feels the same as a ten-clip one: instant. And when there is real work to do — a big drop of footage to index — use every core that one machine has, right up to the edge of oversubscription and no further.

Point it at a big folder and feel it stay fast

Thousands of clips, fully local, still instant to search.

Download for macOS