Performance & Tuning
UltraTorrent's performance story is dominated by two hard walls that both appear suddenly, at scale, after everything worked fine in testing:
- The IMDb catalogue — 8.9 million rows, and a query pattern that cannot use a normal index.
- rTorrent 0.9.8 — an upstream crash bug whose frequency scales with your torrent count.
Everything else is ordinary tuning. Those two are not; both have caused real outages, and both are documented here with the actual measurements.
Purpose
To keep UltraTorrent fast as your library grows from tens to thousands of items, and to know which wall you are about to hit.
When to use this
- A library scan is slow, or never finishes.
- Searches take seconds.
- The engine keeps restarting.
- Downloads sit queued and never start.
- You are planning a large library and want to avoid all of the above.
Prerequisites
- Shell access, and
psqlviadocker compose exec postgres. - Familiarity with Troubleshooting — the two worst performance failures present as outages, and are documented there.
Video coming soon.
Concepts
The scaling map
The IMDb catalogue
This is the single most important performance topic in UltraTorrent, because the failure mode is not "slow" — it is a scan that hangs forever.
The problem, precisely
Prisma renders mode: 'insensitive' as SQL ILIKE. And:
ILIKEcannot use a btree index.
On a small table nobody notices. On the 8.9-million-row IMDb catalogue, every case-insensitive title lookup became a full table scan. Measured on a live production host:
| Value | |
|---|---|
| Catalogue size | 8,900,000 rows |
| Query | primaryTitle ILIKE ... + ORDER BY startYear DESC |
| Plan | Planner walked a btree index backward, ILIKE-filtering the whole table |
| Time per lookup | 47.8 seconds |
And these lookups fire per media item — during show-status warm-up, identification, and missing-episode self-heal. So a library scan issued thousands of them, which saturated Postgres and starved every other query, including the scan's own. The scan sat at 8%, then 41%, then 74%, and never completed.
The fix: pg_trgm GIN indexes
Trigram indexes make LIKE/ILIKE index-backed.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS imdb_titles_primary_title_trgm
ON imdb_titles USING gin ("primaryTitle" gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS imdb_titles_original_title_trgm
ON imdb_titles USING gin ("originalTitle" gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS imdb_akas_title_trgm
ON imdb_akas USING gin (title gin_trgm_ops);
The result, same query, same 8.9M rows:
| Before | After | |
|---|---|---|
| Plan | Backward index scan + filter | Bitmap Index Scan |
| Time | 47.8 s | 180 ms |
| Speedup | — | ~265× |
No application code changed. The query was correct all along; the index was missing.
You probably don't need to do this by hand
Current builds handle it. The migration does only the cheap
CREATE EXTENSION IF NOT EXISTS pg_trgm; a dedicated service then builds the
three GIN indexes at runtime, in the background, with
CREATE INDEX CONCURRENTLY IF NOT EXISTS:
- A fresh install builds them instantly (the catalogue is empty).
- An existing install back-fills them with zero downtime.
- It is idempotent — a no-op once they are valid.
- It detects an index left INVALID by an interrupted
CONCURRENTLYbuild, drops it, and rebuilds it.
CREATE INDEX inside a migrationThis is not a style preference — it caused a real outage on two hosts at once.
CREATE INDEX on the full catalogue takes minutes and holds a lock. When one was
killed mid-flight, Prisma marked the migration failed (P3009) and the backend
refused to boot at all, restart-looping until the migration row was resolved by
hand.
And CREATE INDEX CONCURRENTLY cannot run inside a transaction, so it can
never live in a Prisma migration regardless. Build big indexes at runtime. See
P3009.
Verify your indexes are present and valid
An INVALID index is worse than no index: the planner ignores it, but its name
exists, so IF NOT EXISTS skips the rebuild forever.
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
WHERE c.relname LIKE '%trgm%';
All three must show indisvalid = true. If one is false:
DROP INDEX CONCURRENTLY IF EXISTS <the_invalid_index>;
-- then restart the backend and let the service rebuild it
Then prove the planner uses it:
EXPLAIN ANALYZE
SELECT * FROM imdb_titles WHERE "primaryTitle" ILIKE 'The Expanse';
-- Want: Bitmap Index Scan. Do NOT want: Seq Scan.
The second IMDb lesson: don't ask SQL to fold accents
A related discovery. Matching a title case-, punctuation- and
accent-insensitively in SQL means ILIKE, which — even with trigrams — meant
Postgres was doing a parallel sequential scan of all 8.9M titles for series
resolution, measured at ~8 seconds per show.
The fix was architectural rather than an index: load the catalogue's TV slice
only (~325,000 tvSeries/tvMiniSeries rows) into memory once, and index it
by an accent/punctuation-folded key.
| Time | |
|---|---|
| Load the TV slice once | 7.2 s |
| Then, per lookup | ~7 ms |
With a 60-second TTL, a whole sweep pays the load once. The lesson generalises: when you need fuzzy matching over a bounded subset, a hash lookup in memory beats any index.
rTorrent at scale
The crash ceiling
The bundled engine is rTorrent 0.9.8 (the maintained jesec v0.9.8-r16 static
binary — already the newest in that lineage). It carries an unfixed upstream
bug:
internal_error: priority_queue_insert(...) called on an invalid item
fired during tracker-announce scheduling. There is no fix in the 0.9.8 lineage (rakshasa/rtorrent#939).
The defining property is that it is load-driven. Two live hosts, identical build:
| Host | Active torrents | Crashes |
|---|---|---|
| A | 752 | 44 in 4 days (~10/day) |
| B | 7 | 0 |
Each crash exits the process cleanly, Docker restarts it, and the saved session reloads — no torrents are lost — but transfers pause and everything re-announces.
It is an upstream bug, not a configuration problem. The mitigations below reduce the blast radius; only changing engine or reducing torrent count changes the outcome.
What to do about it
In order of impact:
-
Move to qBittorrent for a large library. UltraTorrent's engine layer is multi-engine by design, and qBittorrent handles thousands of torrents comfortably. This is the durable answer.
docker compose --profile qbittorrent up -ddocker compose logs qbittorrent | grep -i password # first-run temp passwordThen register it under Infrastructure → Engines (kind qBittorrent, base URL
http://qbittorrent:8080). If the test fails with 401, disable Enable Host header validation in qBittorrent's Options → Web UI — the backend connects by the service name. -
Keep the active torrent count modest. Remove or stop completed seeds. The crash rate falls with the count.
-
Already done for you: UDP tracker announces are disabled (
trackers.use_udp.set = no), removing a secondary crash variant. DHT is off by default (RT_DHT=off) for the same reason. Neither fixes the dominant crash. HTTP/HTTPS trackers and PEX still find peers. -
A healthcheck surfaces a wedged engine. The Compose healthcheck checks the SCGI port is listening, so the rarer "process alive but SCGI wedged" state shows as
unhealthy. (A crash already exits the process, which Docker recovers on its own.)
Monitor it:
docker inspect --format '{{.RestartCount}}' $(docker compose ps -q rtorrent)
Download queue throughput
Dead torrents can consume every queue slot
A subtle throughput killer, and a real one: an engine holding 1,137 torrents and moving 0 bytes. Not slow — zero. The network was fine (DHT healthy, 366 nodes; trackers answering). The torrents were simply dead — 1,114 of 1,137 had zero seeders.
The mechanism:
A 0-seeder magnet can never fetch its metadata — yet the engine counts it as an active download the entire time it tries.
With max_active_downloads: 100, exactly 88 metaDL + 12 stalledDL = 100
slots permanently held by torrents that will never finish. The other 1,034
torrents — including all the healthy ones — sat in queuedDL behind them, unable
to start.
The root cause was upstream of the engine: two of four indexers had no
minSeeders set, and the per-indexer seeder filter only applies when that
column is set. So 0-seeder releases sailed straight through.
The fixes
-
Set
minSeederson every indexer. This is the prevention. An indexer with nominSeederswill hand you corpses. See Indexers. -
Enable the parking queue. A background service (every 5 minutes) pauses torrents that are
DOWNLOADING, belowminSeeders, with nobody connected, no bytes moving, and past a grace period. A paused torrent holds no slot, so the engine promotes a queued one into the freed slot — the queue drains its own dead weight.It handles the obvious trap too: a paused torrent never announces, so its seeder count could never refresh, and parking would be a one-way trip. So each tick it force-starts a batch of parked torrents, reads the outcome next tick, and releases any that found seeders. Persistently dead ones back off exponentially.
It never touches a
QUEUEDtorrent (costs no slot) or aPAUSEDone (a human paused that deliberately).Ships disabled by defaultYou must enable it. It is an automatic pauser, and turning it on is a deliberate operator decision.
-
Raise
max_active_downloadsonly if your slots are held by healthy torrents. If they are held by corpses, raising the limit just admits more corpses.
Large media libraries
| Symptom at scale | Cause | Fix |
|---|---|---|
| Scans crawl or hang | Missing trigram indexes | pg_trgm |
| Scans interrupted by restarts | Job bodies run in-process — a restart orphans them | Don't restart mid-scan. Orphans are reconciled (failed out) at boot |
| Shows show 0 missing episodes | Identification misses | Media identification |
| Library fragmented into one entry per episode | Items created with title = basename(file) and no season/episode (fixed; self-heals on re-scan) | Upgrade and re-scan |
| Dashboard activity feed is all noise | Bursty background sweeps write one audit row per item | Fixed — bursts are collapsed into a single "N events" line |
Postgres tuning
The stock postgres:17-alpine defaults are conservative. If you have imported the
IMDb catalogue and have RAM to spare, these help:
# docker-compose.override.yml
services:
postgres:
command:
- postgres
- -c
- shared_buffers=1GB # ~25% of RAM available to Postgres
- -c
- work_mem=32MB # per sort/hash; raises GIN scan speed
- -c
- maintenance_work_mem=512MB # makes index builds much faster
- -c
- effective_cache_size=3GB # tells the planner how much OS cache exists
- -c
- random_page_cost=1.1 # you are on SSD; the default 4.0 assumes spinning rust
random_page_cost is the highest-leverage one-linerThe default of 4.0 assumes a spinning disk and biases the planner against
index scans. On SSD/NVMe, 1.1 makes it correctly prefer the index. This alone can
flip a bad plan to a good one.
Then keep statistics fresh — the planner cannot pick a good plan from stale stats:
ANALYZE imdb_titles;
ANALYZE imdb_episodes;
ANALYZE media_items;
Resource sizing
| Deployment | RAM | Notes |
|---|---|---|
| Small (no IMDb catalogue) | 2 GB | Comfortable |
| Medium (IMDb imported) | 4 GB | The catalogue import and index builds want headroom |
| Large (IMDb + thousands of torrents + qBittorrent) | 8 GB+ | qBittorrent's own memory grows with torrent count |
The IMDb dataset import is the single most resource-hungry operation the app performs. Give it room, and do not run it concurrently with a library scan.
Headless Chromium crashes with Docker's default 64 MB /dev/shm. The shipped
Compose file sets shm_size: "256m" for FlareSolverr. If you run it elsewhere,
do the same.
Examples
Find your slowest queries
docker compose exec postgres psql -U ultratorrent -d ultratorrent -c "
SELECT pid, now() - query_start AS duration, state, wait_event_type,
left(query, 100) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY duration DESC
LIMIT 10;"
Remember the reading rule from the real incident: state = active, no lock
contention, and plenty of free connections means starved, not stuck — one query
is so expensive it is eating the server. That points at a missing index, not at
concurrency.
Confirm a title lookup is index-backed
docker compose exec postgres psql -U ultratorrent -d ultratorrent -c "
EXPLAIN ANALYZE SELECT * FROM imdb_titles WHERE \"primaryTitle\" ILIKE 'Silo';"
Want: Bitmap Index Scan. Do not want: Seq Scan.
Check table sizes
docker compose exec postgres psql -U ultratorrent -d ultratorrent -c "
SELECT relname, n_live_tup,
pg_size_pretty(pg_total_relation_size(relid)) AS size
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;"
imdb_titles will dominate. That is expected.

Troubleshooting
| Symptom | Go to |
|---|---|
| A scan freezes and never completes | Troubleshooting |
| Nothing downloads; everything is queued | Troubleshooting |
| rTorrent restarts constantly | Troubleshooting |
| Indexes exist but are ignored | Troubleshooting |
| Jobs stuck at 0% forever | Troubleshooting |
Tips
- Import the IMDb catalogue once, deliberately, and let the trigram indexes finish building before you kick off a big library scan.
EXPLAIN ANALYZEis not optional when a query is slow. Guessing at indexes is how you end up with an INVALID index nobody uses.- Load-driven bugs are invisible in testing. rTorrent at 7 torrents is flawless and at 750 crashes ten times a day. Test at your real scale, or plan for it.
- Don't restart mid-scan. Jobs run in-process; a restart orphans them.
- A queue full of dead torrents looks exactly like a network problem. Check seeders before you blame your ISP.
FAQ
Do I have to import the IMDb catalogue? No — it is optional and off by default. Without it you lose local title resolution and missing-episode detection quality. With it, you gain 8.9M rows and must have the trigram indexes.
How long do the trigram indexes take to build?
Minutes on a fully-imported catalogue. They build CONCURRENTLY in the background,
so the app stays up — but lookups are slow until they are done.
Why is qBittorrent recommended over the bundled rTorrent? Because rTorrent 0.9.8 has an unfixable, load-driven crash bug. At a handful of torrents it does not matter. At 750 it crashes ~10 times a day.
Will raising max_active_downloads speed things up?
Only if your slots are held by healthy torrents. If they are held by 0-seeder
corpses, you will just admit more corpses. Fix minSeeders first.
Is Redis a bottleneck? Very unlikely. It is a cache and job broker; it is not where your time goes.
Checklist
Database
-
pg_trgmextension exists - All three GIN trigram indexes exist and
indisvalid = true -
EXPLAINon a title lookup shows a Bitmap Index Scan -
ANALYZEhas been run since the last big import -
random_page_costis lowered if you are on SSD
Engine
- You know your active torrent count
- If it is in the hundreds, you are on qBittorrent (not rTorrent)
-
RestartCountis not climbing
Queue
- Every indexer has a
minSeedersset - The parking queue is enabled (if you run a large back-catalogue)
- Active-download slots are held by torrents that are actually moving bytes
See also
- Troubleshooting — the incidents behind this page
- Configuration Profiles — sized, known-good settings
- Maintenance — keeping it fast
- Engines · Indexers · Media Manager
- Database schema · Environment