Skip to content

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: 4

Practical 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.

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 exist

Cleanup 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).

DB_POOL_MIN_CONNECTIONS: 2 # kept open, per worker per database
DB_POOL_MAX_CONNECTIONS: 10 # opened on demand, per worker per database

max 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.

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 pooler

Note 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 host

This 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.

SymptomWhat it usually means
CPU sustained near workers × 100%Workers are the constraint — raise them
CPU low, requests slowWaiting on the database — look at queries and pool sizes, not workers
A screen loads in visible wavesDB_POOL_MAX_CONNECTIONS below the number of parallel requests a page makes
The first request after idle is slowDB_POOL_MIN_CONNECTIONS is 0, so connections are opened on demand
Memory grows and does not come backPer-database caches accumulating — check DEAD_POOL_SWEEP_SECONDS
Connection errors under loadTotal 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.

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.