Emby large library slow, high memory

Big libraries are where media servers start to drag: the poster wall pauses, filters make you wait, memory keeps climbing. People have been reporting constant time-outs with large libraries on Emby's forum for years. This is the first in a short series on the choices behind Kryspal Media.

Why big libraries slow down

Every screen in a player is a query: the first 50 movies by name, only the unwatched ones, the episodes of one season. If the server has to look at every title to answer, the cost grows with the library. The fix is to never look at most of it.

Two panels: without an index every title is read and the cost grows with the library; with an index the lookup goes straight to the entry
The same query with and without an index.

Indexes built for the questions players ask

Kryspal Media keeps titles, people, tags and watch history in PostgreSQL, with one composite index for each common query: browsing by name, by date added, release date and rating, seasons and episodes, and cast and tags in both directions. "Continue watching" uses a partial index, an index over only the rows that match a condition, here the items you have started and not finished, so it stays small at any library size. The server reads the answer in index order and stops after one page.

Rows of per-query indexes: by name, by date added, by rating, person to titles, and a short partial index for continue watching; each reads its answer in order and stops after one page
One index per common query, each already in the order the player asks for.

Pages that do not count rows

Offset pagination, "skip the first 40,000 and give me 50", must walk past those 40,000 rows, so deep pages get slower. Kryspal Media uses keyset pagination, also called cursor pagination: it remembers the sort key and ID of the last item and reads the next 50 straight from the index, so the cost does not depend on depth. Players that only ask by position are served from a short-lived sorted list of IDs.

Two panels for page 800: OFFSET 40000 LIMIT 50 reads and discards 40,000 rows, while keyset pagination starts right after the last item of the previous page
Offset pagination against keyset pagination.

Two databases, two designs

Emby stores its library in SQLite: a single file inside the server process, with one writer at a time. SQLite’s built-in full-text search splits text at spaces, so part of a word, like "stellar" in "Interstellar", or a Chinese title with no spaces, can't be found through the index.

Kryspal Media uses PostgreSQL. Reads and writes run side by side (MVCC, multi-version concurrency control), so a scan never stalls browsing. A bigram index (pg_bigm) splits text into pairs of characters, so any part of a title, even one or two Chinese characters, is found straight from the index. The bigger the library, the bigger the gap.

Two scenes comparing Emby (SQLite) with Kryspal Media (PostgreSQL). Searching for part of a title: SQLite's full-text index cannot match it and checks titles one by one, while PostgreSQL splits the title into pairs of letters and finds it at once. While a scan is running: SQLite queues writes one after another, while PostgreSQL handles scan, playback progress, mark watched and browse at the same time.
Illustration: the same question, two databases.

A restart that does not start over

The library lives in the database, so a restart does not rescan your files or reload every title. Memory settings for the server and the database are calculated at startup from the memory the container has.

A bar from start to ready, under 5 seconds, with no rescan of files and no reload of every title
Restart: from startup to ready.

High memory usage: a fixed upper limit

The server (written in Go) reads the container's memory at startup and sets the Go runtime's soft memory limit (GOMEMLIMIT) to about a quarter of it, within fixed bounds; a value you set yourself wins. Near the limit, garbage collection runs more often and idle memory goes back to the operating system.

The database (PostgreSQL) sizes shared_buffers, its page cache, from the available memory and keeps it fixed. When it is full, clock-sweep evicts the least recently used pages, so the cache does not grow with uptime; a query's work_mem is released when the query ends.

On a real library of about 177,000 titles, the server uses about 140 MB and the database about 1 GB including its buffers. On a synthetic library of about 21,000 titles, a first scan with 1 GB of memory finished without running out of memory, with the same screen latency as with 4 GB.

Top: memory over time with the Go soft limit and garbage collection running more often near it. Bottom: PostgreSQL shared_buffers has a fixed size and evicts the least recently used page when a new one comes in
How memory is kept in check: the Go soft limit and the fixed PostgreSQL buffer cache.

What you get

Bar chart: median response time for paging, sorting, filtering and person pages, Emby 4.10 against Kryspal Media
Median response times for six browsing, sorting and filtering actions, one user.
  • Browsing and filtering up to 30× faster than Emby 4.10; the Compare page has the details.
  • On a library of about 200,000 movies, browsing answers in under 50 ms.
  • After a restart, the server is ready in under 5 seconds, even with a huge library.

It is the same on the free plan.

Move over without starting from zero

Install Kryspal Media with Docker next to Emby, point it at the same folders, and open your big library in both. The import is guided and read-only: libraries, members, metadata, images, watch history, favorites and tags come across as they are, and Infuse, SenPlayer and VidHub connect the way they do to Emby. See the migration guide.

Diagram: Emby (read-only) to the import wizard to Kryspal Media, which receives libraries, members, metadata, images, watch history, favorites and tags; Infuse, SenPlayer and VidHub connect as before
Moving over from Emby: a read-only import.

Test setup: a home NAS (Intel i5-1235U) with the database on an NVMe SSD; a real library of about 177,000 titles, with the browse comparison run on a 68,000-movie library within it; October 2026.

All posts