Performance and Scalability
Guide to performance tuning and expected behavior for large libraries.
Overview
CuePoint is designed to handle large Rekordbox libraries (10k+ tracks) while maintaining responsive UI and predictable performance. Use this guide to understand expected throughput, memory usage, and tuning options.
The library
Measured, not estimated. The numbers below come from
python scripts/bench_library.py, which generates a collection, imports it,
refreshes it and prints this table. Run it yourself if you want the numbers for
your machine — these are from a Windows 11 desktop, Python 3.12, on an SSD.
50,000 tracks, 250 playlists, 175,000 playlist entries (a 20 MB export):
| Operation | Time | Peak memory |
|---|---|---|
| Import | 10.9 s | 52 MB |
| Re-import the same file | 13.9 s | 112 MB |
| Check Rekordbox for changes, nothing changed | under 10 ms | negligible |
| Check Rekordbox for changes, something changed | 12.1 s | 69 MB |
| Apply a refresh | 14.0 s | 112 MB |
The resulting library file is about 20 MB.
Checking an unchanged collection is instant on purpose. Reading a 50,000-track export to conclude that nothing happened takes 11 seconds — as long as importing it — and re-checking an untouched file is the common case. CuePoint compares the export’s modified time and size against what it recorded at import and answers from that, so the Library page can tell you where you stand without a wait. It only ever answers “nothing changed” this way; anything that might have changed is read in full.
Memory is the peak Python allocation for a single pass, roughly 1 KB per track for an import. Both the parse and the comparison stream the file rather than loading it, so a bigger collection costs proportionally more, not catastrophically more.
Browsing the library
The queries behind the Library table run on every scroll, sort and playlist selection, so these are milliseconds rather than seconds. Same machine, same 50,000-track library, same script.
| What you did | Time |
|---|---|
| Open the library | 1.4 ms |
| Scroll to the very end | 5.1 ms |
| Sort by any other column | 16–19 ms |
| Type a search term | 19 ms (24 ms for the count) |
| Select a playlist | 1.9 ms |
| Select a folder of playlists | 48 ms |
Sorting the library by artist is faster than sorting it by anything else because one database index covers exactly that order. That index is also what keeps scrolling to the end of a 50,000-track library instant: without it the same request takes 717 ms instead of 5 ms, because the database has to sort the whole library to find the last hundred rows.
It costs about 1.8 MB of database file and roughly 1% of import time (10.29 s → 10.40 s measured), which is why it exists and why six other indexes that were tried alongside it do not: they changed no measured time and cost ten times as much.
A folder is the slowest selection because it gathers every playlist beneath it and counts each track once, however many of those playlists it appears in.
Filtering the library
Filters and the lists of choices behind them, on the same 50,000-track library:
| What you did | Time |
|---|---|
| Apply a filter | 13 ms |
| Add two more rules to it | 12 ms |
| Open the rating list | 7 ms |
| Open the genre, key or label list | 35–43 ms |
| Open the artist list (900 artists) | 12 ms |
| Re-open a list while other filters are on | 35 ms |
| Read the BPM range | 23 ms |
The lists of choices — every genre in your library and how many tracks each has — are the expensive part, because answering means visiting every track. Seven database indexes make that ten times faster: opening the genre list went from 117 ms to 11 ms, the label list from 119 ms to 12 ms, and the rating list from 65 ms to 7 ms.
They cost 4.9 MB of database file and about 5% of an import time (10.40 s → 10.92 s). Artist, album and remixer are deliberately left out of them: those lists are long tails rather than a handful of choices, an index each would be far larger, and they still answer inside the same budget.
The genre, key and label lists count the value you see, which is your own correction where you have made one. That means reading two tables rather than one, measured with 10,000 corrected tracks in the library: 35–43 ms rather than 11–13 ms. The tracks you have not corrected are still counted through the indexes above, and only the corrections are counted separately. Counting both together in one pass took 59–68 ms. Filtering and sorting by those five fields cost the same kind of difference: a first page sorted by BPM takes 25 ms rather than 14.
With CuePoint’s Clean facts in the table — 30,000 tracks matched and 10,000 corrected — a window of 100 rows takes 3.5 ms, of which reading where each correction came from is about half a millisecond. Sorting the first page by match state or file status takes 36–39 ms. Sorting it by match score took 155 ms, because each score was read from the stored Beatport candidates one track at a time; since CLEAN-14 the score is kept beside the match, and it takes 35 ms like the others — see Clean.
Re-opening a list while other filters are on is the slowest of these, and deliberately so: with a filter in play CuePoint reads the library directly instead of through those indexes, which is five times faster than the alternative (35 ms rather than 178 ms).
Clean
Measured with python scripts/bench_clean.py on the same machine, on a
50,000-track library carrying everything Clean adds: 30,000 matched tracks
with 40 Beatport candidates each (1.2 million candidates), 8,000 of your
decisions, 10,000 of your own values, 2,000 missing files, 1,500 possible
duplicate groups, and artwork state for every track. Forty candidates is
what a live match stored per track when CLEAN-02 measured it.
| What you did | Time |
|---|---|
| Open the review queue (12,000 tracks need review) | 15 ms |
| Scroll to the end of it | 33 ms |
| Sort it by score | 43 ms |
| Open Health, every count | 313 ms |
| One Health count, followed into the Library | 10–30 ms (no artwork: 155 ms) |
| Sort the Library by BPM, key, match, score or file status | 35–47 ms |
| Scroll to the end of the Library sorted by BPM | 131 ms |
| Check every file (48,000 present, 2,000 missing) | 8.6 s |
| Look for duplicates | 2.3 s |
| Apply Beatport’s values to 5,000 tracks | 3.2 s |
| Revert that (25,000 changes) | 3.4 s |
The library file is 299 MB at this size, almost all of it the kept candidates: about 250 bytes each. A library that has not been matched is about 20 MB. Compacting it gains little (293 MB), because nothing is ever deleted from it in normal use.
Scrolling deep into a sorted library became five times faster while this was measured. A window far down a library sorted by anything but artist took 0.6–0.75 s, because the database sorted whole rows — twenty columns and a path each — to find a hundred of them. It now sorts only the tracks’ ids and what they are sorted by, then reads the hundred rows: 131 ms for BPM, 55 ms for title. Sorting by artist still uses its index and takes 3 ms.
No artwork is the slowest count because it asks, for all 45,000 tracks without a picture of their own, whether an accepted Beatport match would give them one. Health is read once when you open it.
The file check and the duplicate scan run in the background after every import and refresh, with a progress bar and Stop; the apply and its revert run in the background too.
Your own Collections, tags and ratings
Everything CuePoint adds on top of Rekordbox — Collections, Smart Collections, tags, ratings, favorites and notes — is measured on the same library, with the organization a heavy user would build on top of it: 200 Collections in a tree of 8 folders, 40 tags across 199,940 tag assignments, and one Collection holding 5,000 tracks. Same machine, same script.
| What you did | Time |
|---|---|
| Open a 5,000-track Collection | 9 ms |
| Filter by “is in this Collection” | 4 ms |
| Filter by a tag | 13 ms |
| Filter by two tags | 11 ms |
| Open the tag list to choose from | 60 ms |
| Draw the Collections tree | 1 ms |
| Open the tag manager | 15 ms |
| Drag a track to a new place in a 5,000-track Collection | 9 ms |
Building all of that in the first place — 200 Collections, 40 tags and 199,940 assignments — takes 13 seconds, which is the only number here you would ever wait for, and it stands in for months of a real person’s filing.
The tag list is the slow one, and it is slow for the same reason the genre list is: answering “how many tracks carry each tag” means visiting every assignment, and there are two hundred thousand of them. It is the one number here you might notice, and it only happens when you open the list.
Opening a Collection was sixty times slower before this was measured. On the same library, a 2,000-track Collection took 183 ms to show its first page and now takes 3: the database had been re-reading the whole Collection once for every track in it while working out the order, and it reads it once now. The cost grew with the square of the Collection’s size, so the 5,000-track case in the table above was worse again.
This is what a measurement is for. Nothing about the feature looked wrong, and a 25-track Collection — which is what every test used — was instant either way.
Changing a lot of tracks at once
| What you did | Time |
|---|---|
| Favorite every track in a 50,000-track library | 11.4 s |
| Check a refresh that deletes tracks 72 Collections are using | 11.1 s |
| Apply that refresh | 14.0 s |
A change over everything a search matches runs in the background with a progress bar and a Cancel button, so the 11 seconds above are 11 seconds you can keep working through. CuePoint never sends fifty thousand track numbers anywhere: it sends the question, and the answer is worked out once, in the database.
Prepare
Measured with python scripts/bench_sets.py on a 50,000-track library shaped as
Similar tracks was measured (70% of it between 120 and 130 BPM), holding a Set
at its largest: 1,000 entries in 10 chapters, each with a target and a BPM
range, half of them timed, and 100 warnings accepted. Two runs, the second
figure in each pair from the second run.
| What you did | Time |
|---|---|
| Upgrade a library to hold Sets (500 Collections and folders, 100,000 entries, 400 export records) | 324 / 341 ms, once |
| Open the Set: its plan | 12 / 12 ms |
| Its running order, as the page receives it | 62 / 55 ms |
| Check every transition, entry and chapter, as the page receives it | 45 / 43 ms |
| Suggestions for a gap in the busiest tempo band (30,832 tracks scored) | 329 / 345 ms |
| Suggestions after the last entry | 33 / 30 ms |
| Suggestions for a gap nothing bridges | 31 / 30 ms |
Suggestions in the busiest band are the one number you might notice, for the same reason Similar tracks is: a gap between two tracks at 124 BPM has most of the library inside its tempo window, and every candidate is scored against both neighbors. It is well inside the half-second budget it is held to, and it only happens when you select a new gap. The end of the Set and a gap nothing bridges are each answered in about 30 ms.
The upgrade runs once, on the first launch of the version that adds Sets, after
the launch backup. It changes no row of any Collection, and bench_sets.py
checks that on every copy it upgrades.
Cue points and beat grids
Measured with python scripts/bench_marks.py on a generated 50,000-track
collection, in two shapes: a typical one (every track analyzed with a grid,
40% of them prepared with five cues, 3.3 marks a track) and one where every
track is prepared with a grid and seven cues. Two runs, the second figure in
each pair from the second run.
| What you did | Time |
|---|---|
| Read a typical collection, with its cues and grids | 2.38 / 2.39 s (1.97 s without them) |
| Read a collection where every track is prepared | 3.37 / 3.39 s (2.64 s without them) |
| Import it: 350,000 cues and 50,000 grid markers | 9.6 / 9.5 s (6.4 s without them) |
| Check Rekordbox for changes after one cue moved in Rekordbox | 7.6 / 7.5 s |
| Read the cues of a library imported before this release | 6.7 / 6.6 s, once, in the background |
Reading cues adds about a fifth to reading a typical collection, and a little over a quarter when every track carries eight marks. Most of the rest of an import’s extra time is writing them, once. A refresh compares each track’s cues with the stored ones without holding them all in memory.
Waveforms
Measured with python scripts/bench_waveforms.py on Windows 11, over a
50,000-track library whose every file has a waveform, its loudness, and eight
cue points and grid markers. Two runs, the second figure in each pair from the second run; the
time is the slowest of the common case (p95).
| What you did | Time |
|---|---|
| Waveforms and their loudness kept on disk for 50,000 tracks | 252.1 MB (about 5 KB a track) |
| Scroll the Library with the Waveform column: 200 rows’ pictures | 23.6 / 23.0 ms |
| Scroll it with the Loudness column: 200 rows’ numbers | 5.1 / 5.2 ms |
| Play a track: its picture for the player bar | 0.18 / 0.18 ms |
| Select a track: its picture and its cues for Track details | 0.27 / 0.26 ms |
| The analysis picks its next 200 files | 384 / 378 ms, once every 200 files |
| The analysis counts the library for the status strip | 371 / 363 ms, at most every 5 seconds |
Drawing waveforms costs nothing you would notice. The Library’s busiest case, a screen of rows asking at once, is answered in about 20 ms, and the column reads only rows that stay on screen. Scrolling 5,000 rows with the column shown records no task long enough to make the window stutter.
The analysis itself takes hours, once. About 6,000 six-minute tracks an hour with two workers on a 16-core Windows desktop (5,712 and 5,977 in two runs), so about eight and a half hours for 50,000 tracks. That includes measuring each track’s loudness, which costs about a third of the rate: the waveforms alone ran about 8,400 an hour. A library analyzed before loudness was measured is measured once more at the same rate. It runs at low priority: while it ran, the Library’s search stayed within 1.4 times its idle speed (1.07 and 1.37) and the player had no dropouts. After that only new and changed files are analyzed. See Waveforms.
Memory while browsing
The numbers above are for the work done behind the window. This one is the window’s, measured in the
packaged app with CUEPOINT_E2E_MEMORY=1 npx playwright test e2e/libraryBrowse.spec.ts -g memory from apps/desktop-electron/, which imports a 50,000-track
collection and scrolls the length of it twice.
| Renderer working set | |
|---|---|
| Library open, before scrolling | 105 MB |
| After scrolling through all 50,000 tracks | 159 MB |
| After a second pass over the same tracks | 160 MB |
The second pass is the number that matters. Browsing the whole library costs about 54 MB the first time — buffers, fonts and compositing the window warms up on any long scroll — and about 1 MB every time after that. Nothing accumulates, because nothing is kept: 29 row elements were in the page at the end of a 50,000-row scroll, and the rows behind them are fetched a window at a time and dropped when they are far enough behind.
On an Apple M5 Pro (macOS 27) the app built from source measured 154–157 MB at the start,
210–216 MB after one pass and 212–217 MB after a second, over three runs (2026-10-10):
the same shape, about 2 MB added by the second pass. The same machine scrolled the
5,000-track table with waveforms (e2e/waveformPlaces.spec.ts, the packaged app) three
times with no task over 50 ms at 1x or 1.5x.
On Windows 11 (16 cores, 2026-10-11) the packaged app measured 121–126 MB at the start, 186–205 MB after one pass and 186–203 MB after a second, over three runs, with no task over 50 ms at 1x or 1.5x in any of them; the 5,000-track table with waveforms also scrolled three times with no task over 50 ms.
Motion
All ten kinds of motion are on in these measurements (Settings > Motion, Turn all
on), and the system’s Reduce motion setting is off. Motion moves only transform
and opacity, steps in whole pixels, and never runs on a track table’s rows while
it scrolls (placeholder rows hold still until the scroll stops, and a row’s hover bar
is drawn only on the row under the pointer and not while the table scrolls), so
it should not show in scrolling. These are the checks that say so, from
apps/desktop-electron/ on a Linux build under a virtual display; each watches for
a task longer than 50 ms, with a task made long on purpose afterward to prove the
watcher sees one.
| Check | Rows | At 1x (33 px rows) | At 1.5x (the default) |
|---|---|---|---|
e2e/waveformPlaces.spec.ts, scroll the whole table with the Waveform column shown |
5,000 | no long task (385 pages scrolled, 35 waveforms on screen at once) | no long task (1,288 pages, 25 waveforms) |
CUEPOINT_E2E_MEMORY=1 e2e/libraryBrowse.spec.ts -g memory, two laps over the library |
50,000 | no long task | no long task |
These pass on a Linux runner with time to spare, and the Mac runners are slower, so the same scroll was also measured on Linux with the processor slowed three times (Chrome’s CPU throttling), three runs before and three after three changes: the table now scrolls on the compositor (on a display that is not high-resolution it had scrolled on the main thread, with every row on screen a layer of its own), the hover bar is drawn only on the row under the pointer, and waveform canvases are painted a few milliseconds at a time instead of a page of them at once. At 1.5x, frames that ran past 50 ms went from 14 to 23 a scroll to 2 to 8 and the middle frame from 33 ms to 27 ms; scrolling on the compositor alone took the main thread’s work over an unthrottled scroll from 31 s to 24 s.
Memory in that second run did what it did without motion: the renderer’s working set
grew by about 85 MB over the first lap on this machine (160 MB before, 245 MB after)
and by nothing on the second lap, with 27 row elements in the page.
scripts/bench_library.py is untouched by motion and gives the numbers above
(50,000 tracks: import 8.8 s, re-import 11.1 s, an unchanged check under 10 ms,
apply 10.8 s).
Performance Budgets
| Metric | Target | Notes |
|---|---|---|
| Startup to ready | < 2s | Modern machine |
| XML parse (10k tracks) | < 5s | Single-threaded |
| Query generation (10k) | < 3s | Per-track |
| Candidate search (10k) | < 20m | Network-bound |
| Export (10k tracks) | < 60s | Batched writes |
| UI updates | < 200ms | Throttled to avoid stutter |
Benchmark Targets
| Dataset | Target | Use Case |
|---|---|---|
| 1k tracks | < 5 min | Baseline |
| 5k tracks | < 15 min | Typical |
| 10k tracks | < 30 min | Stress |
Run benchmarks with:
python scripts/bench.py --dataset 1k
python scripts/bench.py --dataset all --save scripts/benchmarks/results.json
python scripts/bench.py --dataset 1k --profile # cProfile, top 20 slowest
python scripts/bench.py --dataset 1k --compare-baseline # Fail if regression > 20%
python scripts/bench.py --dataset 1k --update-baseline # Create/update baseline.json
CLI Performance Flags (Design 6.63)
| Flag | Description |
|---|---|
--max-workers N |
Override max parallel track workers |
--max-queries-per-track N |
Override query limit per track |
--benchmark |
Collect and save performance metrics to performance_report.json in output dir |
Example:
cuepoint --xml collection.xml --playlist "My Playlist" --benchmark --max-workers 4
Configuration
Performance Settings
| Setting | Default | Description |
|---|---|---|
performance.max_workers |
8 | Max parallel track workers |
performance.max_queries_per_track |
6 | Query limit per track |
performance.cache_max_mb |
500 | HTTP cache size limit |
performance.runtime_max_minutes |
120 | Abort after 2 hours |
performance.progress_throttle_ms |
200 | UI update interval |
performance.eta_update_every_tracks |
50 | ETA update frequency |
Legacy Settings (still supported)
| Setting | Default | Description |
|---|---|---|
TRACK_WORKERS |
12 | Parallel workers (capped by max_workers) |
CANDIDATE_WORKERS |
15 | Candidate fetch threads |
PER_TRACK_TIME_BUDGET_SEC |
45 | Per-track timeout |
MAX_QUERIES_PER_TRACK |
40 | Query cap |
Adaptive Concurrency
Worker count is capped by performance.max_workers to avoid overloading low-end systems. Default is 8 workers. For high-end machines, increase TRACK_WORKERS in config; it will be capped by max_workers.
Performance Guardrails
- Runtime limit: Run aborts after
runtime_max_minutes(default 120 min). Error code: P001. - Memory limit: Run aborts if process memory exceeds 2GB. Error code: P002.
- Per-track timeout: Each track stops after
PER_TRACK_TIME_BUDGET_SEC(default 45s).
ETA and Progress
- ETA: Estimated time remaining based on average time per track. Updates every 50 tracks.
- Throttling: Progress UI updates every 200ms to avoid stutter.
Cache Metrics
- Cache hit rate: Target > 60% for repeated runs.
- Cache size: Default 500MB. Configurable via
performance.cache_max_mb. - Eviction: LRU eviction when size limit reached.
Incremental Processing
Process only new tracks by reusing a previous run’s output:
python main.py --xml collection.xml --playlist "My Playlist" --incremental /path/to/previous_main.csv
- Skips tracks already in the previous CSV (matched by playlist index, title, artist).
- Appends new results to the existing file.
- Useful when you add tracks to a playlist and want to avoid re-processing everything.
Profiling
Run benchmarks with cProfile to capture hot paths (Design 6.19):
python scripts/bench.py --dataset 1k --profile
This saves the top 20 slowest functions to scripts/benchmarks/profile_1k.txt.
Tuning Tips
- Reduce concurrency if memory is high: lower
TRACK_WORKERSormax_workers. - Increase cache for repeated runs: raise
cache_max_mb. - Reduce queries for speed: lower
MAX_QUERIES_PER_TRACK(may reduce match quality). - Large library warning: If estimated time > 60 min, consider running in smaller batches.
Error Codes
| Code | Meaning | Action |
|---|---|---|
| P001 | Runtime budget exceeded | Run stopped after 2 hours. Split into smaller batches. |
| P002 | Memory budget exceeded | Lower concurrency or process fewer tracks. |