DEV Community

Solon Framework
Solon Framework

Posted on

Solon Server Threads: Zero-Config Auto-Tuning by CPU Cores — ioBound, coreThreads, maxThreads

It was 2 AM, and the on-call chat was on fire again: the order service was healthy on every dashboard, but throughput had flatlined at ~800 req/s while P99 climbed past 4 seconds. The usual suspect? A thread pool sized by guesswork during a late-night deploy, six months earlier. We'd hand-tuned maxThreads to "something that felt right," and it wasn't right anymore.

That's the moment I started appreciating a different default: in Solon, all of those knobs ship as 0 — meaning auto, derived from your machine's actual CPU cores at runtime. You can go months without thinking about a single thread-pool property. This post walks through the five knobs that exist, how the auto-tuning math works, and the three failure modes that tell you it's time to touch them.

The five knobs under the hood

Solon exposes these on app.yml (all values are the documented defaults):

# Minimum threads for the http server (0 = auto; also accepts fixed values like 2, or core multiples like x2)
server.http.coreThreads: 0
# Maximum threads for the http server (0 = auto; also accepts fixed values like 32, or core multiples like x32)
server.http.maxThreads: 0
# Idle thread timeout in ms (0 = auto)   # supported since v1.10.13
server.http.idleTimeout: 0
# Is this an IO-bound service? (default true)   # supported since v1.12.2
server.http.ioBound: true

# Enable the virtual thread pool (default false)   # supported since v2.7.3
solon.threads.virtual.enabled: false
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: no hard-coded defaults for coreThreads or maxThreads. 0 means "figure it out from the hardware." That single decision removes a whole class of "copy-pasted tuning values" problems — the ones that were right for someone else's 32-core box and wrong for your 2-core container.

CPU-bound or IO-bound: the one question that matters

The auto-tuner only needs you to answer one question: is your workload CPU-bound or IO-bound?

  • CPU-bound: the work happens entirely in CPU and memory — think a "hello world" handler that returns a string. Responses are extremely fast. Here, more threads just means more context-switch overhead; you're paying for nothing.
  • IO-bound: the work touches the network card or disk — a CRUD write to a database, an uploaded file flushed to disk. These requests can take 10+ seconds, and each one occupies a thread while it waits. Threads run out fast.

The server.http.ioBound flag tells the framework which world you live in, and the auto-tuner adjusts accordingly.

The auto-tuning math

With server.http.ioBound: true (the default):

Setting Formula Example: 2c4g box
coreThreads CPU cores × 2 4
maxThreads coreThreads × 32 128

With server.http.ioBound: false:

Setting Formula Example: 2c4g box
coreThreads CPU cores × 2 4
maxThreads coreThreads × 8 32

IO-bound services get 4× the headroom — because each thread spends most of its life waiting on IO, you need more of them in flight to keep the machine busy. CPU-bound services stay lean, because the CPU is the bottleneck and extra threads only add switching costs.

Doing the math yourself, the two-minute version

Before you override anything, it helps to estimate what a setting is worth:

Throughput (QPS). If one request takes 0.1 s to respond, one thread can serve ~10 requests per second. 100 threads → ~1000 QPS. Thread count is a direct throughput ceiling.

Memory. Each thread costs at least 1–2 MB. 100 threads ≈ 200 MB just sitting there. A 200 KB request payload can exist in ~4 copies during processing (≈800 KB), so at 1000 QPS you're churning ~800 MB/s of request data — and if GC only frees it after ~5 s, that's ~4 GB/s of live pressure. Thread tuning and memory tuning are the same conversation.

Why coreThreads is the "do not touch" knob

Two reasons, depending on the underlying transport:

  • For BIO servers, a large coreThreads means the pool never shrinks — your "minimum" becomes a permanent resident of RAM.
  • For NIO servers, coreThreads must stay small because the pool is two-staged: coreThreads is stage one, maxThreads is stage two. Bloating stage one defeats the whole design.

Default it, and forget it. If you're going to change anything, it's maxThreads.

Three ways "threads are exhausted" shows up

When maxThreads is genuinely too small, the symptoms are distinct — and each one tells you where the bottleneck is:

  1. It stalls, then recovers. The pool rejects a submission and the main thread takes over the work. The main thread can't accept new requests while it's busy, so things pause — then resume once the backlog clears. Annoying, but survivable.
  2. The protocol returns an error / refuses service. The server actively rejects requests with an error, so the client learns immediately that the server is overwhelmed. This is the polite refusal.
  3. The connection just drops. The threads that parse the protocol are exhausted too — there's no thread left to even craft a proper error response, so the server slams the connection shut.

Symptom 3 is the emergency red line. Symptom 1 is your early warning.

When (and how) to actually override maxThreads

"Leave it at the default" is the honest starting point. The framework's auto values handle the vast majority of single-instance deployments. You reach for maxThreads when you're single-instance with heavy traffic or slow requests — and you size it against your memory budget, not against vibes.

Official guidance example: on a 1c2g container, you might configure x256 → 512 threads. At ~2 MB per thread, that's ~1 GB of thread memory alone, leaving ~1 GB for business data processing. Tight — and the docs say so plainly: it may be borderline, and it may blow up memory. The point isn't the exact number; it's that you should compute the memory cost before you type a bigger number.

Virtual threads: one flag, when your Java allows it

Since v2.7.3, Solon has a dedicated switch:

solon.threads.virtual.enabled: true   # default: false
Enter fullscreen mode Exit fullscreen mode

One property. If your runtime supports virtual threads (Java 21+), this is the "more concurrency without more native threads" lever — the alternative to hand-inflating maxThreads. It exists precisely because the answer to "threads not enough" should not always be "increase the pool."

Context: how server configs relate

If you're wondering where these keys fit in the wider config map: Solon splits server settings into four series — server.* (the master config, reused by all signals), server.http.*, server.socket.*, and server.websocket.*. When a signal-specific value is absent, it falls back to the master config. Ports are the special case: http uses the main port, socket defaults to main + 20000, websocket to main + 15000.

And for the curious: Solon's http signals ship as tiny plugins — solon-server-jdkhttp (0.1 MB, BIO-based) and solon-server-smarthttp (0.5 MB, AIO-based). Your server.http.* knobs tune whichever signal you pulled in. (For context, in the Spring world — general knowledge, not a Solon claim — you'd typically hand-edit something like server.tomcat.threads.max and pick a fixed number yourself.)

The takeaway

Threads aren't "more is better," and they aren't "fewer is better" either — they're a budget you should spend knowingly. Solon's approach is to make the default a computed number (CPU cores × 2 → × 32 or × 8 depending on ioBound) instead of a hard-coded guess, so you only think about threads when your workload genuinely asks for it. When it does: size maxThreads against memory, leave coreThreads alone, and remember the virtual-thread flag is one line away.

Next time your on-call chat lights up at 2 AM, the answer might be a single server.http.maxThreads line — or the discovery that 0 was doing fine all along.


Written while exploring Solon (v4.0.4), an Apache-2.0 Java framework. Facts checked against the official docs (solon.noear.org articles 513, 565, 131).

Top comments (0)