Many self-hosted streaming setups quietly slow down as the library grows: the first pages feel snappy, then deep pages start to spin. We pushed PureStream’s Subsonic server to 500,000 songs and measured every read path—no thresholds, no pass/fail, just real numbers.
🛠 Why 500k
A serious music library is not “a few thousand tracks”; for collectors it is hundreds of thousands. The most-used Subsonic endpoints—search3 enumeration, getMusicDirectory browsing, the getAlbumList2 album grid—have very different cost shapes at scale: some ride covering indexes, others must deduplicate across the whole table. Until it is measured, “fast on large libraries” is just a slogan.
Three things happened in this round:
- [x] Seeded a 500k-row catalog (~42k albums, 2,000 artists, 100 genres) through the full migration chain—a production-shaped SQLite catalog
- [x] Added a missing read index for genre queries and registered it in the schema contract—if the index is missing or dropped, startup fails loudly
- [x] Sampled P50/P95 across 14 read paths and wrote the numbers to a baseline report
📊 Measured results
| Endpoint | 100k | 500k | Verdict |
|---|---|---|---|
| search3 main enumeration | 53µs | 69µs | ✅ Flat |
| search3 deep page (offset=250k) | — | 25ms | ✅ |
| getMusicDirectory single page | 1.3ms | 1.4ms | ✅ |
| getAlbumList2 three paging modes | 8–82ms | 86–333µs | ✅ |
| getSongsByGenre | untested | 113µs | ✅ New index pays off |
| FTS text search | 24.8ms | 137ms | ⚠️ Linear |
| getArtists dedup enumeration | 3.4ms | 543ms | ❌ Super-linear hotspot |
| getGenres aggregation | untested | 600ms | ⚠️ Acceptable ceiling |
| Huge single-directory deep page | — | 734ms | ⚠️ Edge shape |
Numbers worth calling out:
- 69µs main enumeration: the empty-query search3 first page stays microsecond-fast at 500k rows, thanks to a partial covering index that only indexes non-missing rows—so catalog churn from the scanner never perturbs the read path.
- 25ms deep offset: fetching a page at position 250,000 costs 25 ms. Deep paging is not free, but it stays far from seconds.
- 113µs songs-by-genre: the direct payoff of this round’s new index—before the change this path was a full table scan; EXPLAIN now confirms an index search.
⚠️ Two hotspots on record
A baseline’s job is also to quantify the bad news:
- 543ms for getArtists: artist deduplication unions two role columns then groups—every page scans the whole table. At 500k it still lands under a second, so we logged it as acceptable rather than scheduling a fix.
- 600ms for getGenres: genre aggregation walks the index in order, but
COUNT(DISTINCT album)still runs per group—600ms is the honest cost at 500k rows. Acceptable, with a ceiling.
✅ What shipped
- [x] A full 500k read-path baseline: P50/P95 for 14 endpoints
- [x] Genre read index + schema-contract registration + EXPLAIN assertions (regression-proof)
- [x] Quantified records of two super-linear hotspots, with fix directions
⏳ What’s next
- [ ] Cursor-based full enumeration for endpoints without an offset parameter, removing repeated deep scans
- [ ] Folding the three identity reads into one for in-flight streaming checks
500,000 songs, 69 microseconds—“fast on large libraries” is not a slogan, it’s an EXPLAIN plan and a P50 sample.
