Performance & Sizing
Most sizing advice for business applications assumes a prefork server: a pool of processes,
each handling exactly one request at a time and blocking on every database call. There, concurrency
is the process count, which is why such systems are sized at roughly 2 × cores + 1 — you need
enough processes that some can block while others run.
Fullfinity does not work that way, and sizing it as though it does will mislead you in both directions.
Workers are event loops, not request slots
Section titled “Workers are event loops, not request slots”A worker is an asynchronous event loop. It handles many requests concurrently: while one waits on the database, the loop serves others. A single worker can hold hundreds of open connections and serve a busy application.
So the question a worker answers is not “how many people use this at once?” but “how many CPU cores do I want this to use?” A worker stops being enough when it saturates a core doing real work — serializing responses, rendering documents, computing a report — not when a second person clicks at the same time.
WORKERS: 4Practical guidance:
- Start at 2–4. That covers far more concurrent traffic than the same number of blocking processes would.
- Raise it when CPU is the limit, which you can see rather than guess: with N workers, sustained
CPU near
N × 100%under real traffic means they are the constraint. - Raising it does not help a slow database. If requests are waiting on queries, more loops wait in parallel; the queries are the problem.
- One worker is right for a small deployment and makes debugging simpler — no cross-worker cache behaviour to reason about.
What a worker costs
Section titled “What a worker costs”Each worker is a separate process holding, per database it has served:
- a connection pool
- a composed model cache (models, views, ACLs, all resolved for that database)
For a single-database deployment that is a fixed cost per worker, and workers are cheap. For a
deployment serving many databases from one process — a hosting setup, per-tenant databases — it
multiplies: workers × databases. That is the number to think about before raising WORKERS on a
multi-database host.
Two settings bound it:
DEAD_POOL_SWEEP_SECONDS: 300 # release pools/caches for databases that no longer existCleanup on database deletion happens in the worker that performed it; the others have no reason to touch that database again. The sweep is what collects them — including databases dropped outside the application entirely (an operator, a restore, a failed clone).
Connection pools
Section titled “Connection pools”DB_POOL_MIN_CONNECTIONS: 2 # kept open, per worker per databaseDB_POOL_MAX_CONNECTIONS: 10 # opened on demand, per worker per databasemax is close to free. Connections are opened on demand up to this ceiling, so a higher value
costs nothing when idle and stops a page’s parallel requests queueing behind each other. A single
screen can issue a handful of concurrent requests; a max below that serialises them into visible
waves of loading.
min is the one with a price, because it is charged workers × databases. On a single-database
deployment, a warm pool is worth having: the first request of the day does not pay to open a
connection. On a host with many databases, keep it small or at zero — the arithmetic gets large
quickly, and every one of those is a real connection at the server.
Watch the total against the server’s max_connections, especially when the database is shared with
other applications: workers × databases × min idle, with a ceiling of workers × databases × max.
Connection poolers
Section titled “Connection poolers”Running behind a pooler changes two things.
Prepared statements. In session mode nothing changes. In transaction mode a backend is reused across clients, so named prepared statements from a previous client collide:
DB_STATEMENT_CACHE_SIZE: 0 # required behind a TRANSACTION-mode poolerNote the cost — every query then re-plans on the server. Where the pooler supports prepared statements in transaction mode, allow it enough of them for the application’s working set (a single record form issues dozens of distinct statement shapes, and browsing a few models passes a hundred).
Latency. A pooler on the same host still speaks TCP, and by default the driver negotiates TLS. Encryption is pointless for traffic that never leaves the machine and costs a handshake per connection:
DB_SSL: disable # a pooler or database on this hostThis is applied automatically when DB_HOST is a loopback address, but a pooler is usually reached
by hostname — at which point it is no longer recognised as local, and this must be set explicitly.
Diagnosing rather than guessing
Section titled “Diagnosing rather than guessing”| Symptom | What it usually means |
|---|---|
CPU sustained near workers × 100% | Workers are the constraint — raise them |
| CPU low, requests slow | Waiting on the database — look at queries and pool sizes, not workers |
| A screen loads in visible waves | DB_POOL_MAX_CONNECTIONS below the number of parallel requests a page makes |
| The first request after idle is slow | DB_POOL_MIN_CONNECTIONS is 0, so connections are opened on demand |
| Memory grows and does not come back | Per-database caches accumulating — check DEAD_POOL_SWEEP_SECONDS |
| Connection errors under load | Total pool ceiling exceeds the server’s max_connections |
Measure before changing anything, and change one thing at a time — a deployment where two settings moved together tells you nothing about which one mattered.
Set a memory ceiling
Section titled “Set a memory ceiling”Whatever the sizing, give the application container a hard memory limit. Without one, a runaway process competes with everything else on the machine and the kernel chooses what to kill. With one, the container is restarted and nothing else is disturbed.