+1 (850) 665-2441

Concurrency in Camel: Consumer Threads, SEDA, and Thread Pools That Behave

Most Camel performance questions we get asked are really threading questions wearing a costume. "The route is slow." "The consumer lags behind the topic." "Throughput collapses when the third-party API gets slow." Underneath each one sits the same set of decisions: which thread runs this exchange, how many of those threads exist, and what happens when they are all busy.

Camel gives you several knobs — seda, threads(), parallelProcessing, per-component consumer concurrency, named thread pool profiles — and they are not interchangeable. Picking the wrong one usually produces a route that looks faster on a laptop and behaves worse in production. Here is how we reason about them.

The default: one thread, all the way through

A Camel route is synchronous by default. The consumer thread that picks up the message carries the exchange through every step until the route ends. direct: is not a queue; it is a method call with routing syntax on top. That has two consequences worth internalizing:

  • Back pressure is free. If the route is slow, the consumer thread is busy, so nothing new is polled or consumed. The broker's queue depth becomes your buffer, and it is a buffer that survives a restart.
  • The transaction and the error handler stay coherent. One thread means one transaction context, one set of exchange properties, and a redelivery story that a person can follow in a log.

Start here. Every concurrency mechanism below trades some of that coherence for throughput, and you should know which piece you are trading.

Scale the consumer before you scale the route

The cheapest and most honest way to run more messages at once is to let the component do it. JMS has concurrentConsumers (and maxConcurrentConsumers for elastic scaling). Kafka has consumer-level concurrency bounded by partition count. Camel's SFTP and file consumers poll on a scheduler you can configure.

from("jms:queue:orders?concurrentConsumers=10")
    .to("bean:orderProcessor")
    .to("http:inventory-service/reserve");

Ten threads now run the whole route end to end. Each one still owns its exchange from consume to completion, so transactions, redelivery, and back pressure all behave exactly as they did with one. This is the option to reach for first, and in a large fraction of cases it is the only one you need.

Two caveats. Ordering is the obvious one: concurrent consumers destroy per-queue ordering, so if the business needs order preserved per customer or per account, you need message groups, a JMS exclusive consumer, or Kafka partitioning by that key — not a bigger thread count. The less obvious caveat is that concurrency at the consumer is upstream concurrency: it multiplies load on everything the route touches, including the database connection pool and the downstream API. A pool of 10 consumers in front of a connection pool of 5 does not go faster; it goes slower and queues internally where you cannot see it.

threads() and seda: handoffs, and what they cost

threads() inserts a thread pool mid-route: the exchange is handed to a pool thread and the calling thread returns. seda: does something similar through an in-memory BlockingQueue that another route consumes.

Both decouple the consumer from the work. Both also move you off the simple model:

  • The in-memory buffer is not durable. A seda queue's contents die with the JVM. If the messages came off a broker and were already acknowledged, you have lost them. This is the single most common way a Camel estate quietly drops data under load.
  • Transactions do not cross the handoff. A transaction started on the consumer thread commits (or rolls back) without any knowledge of what the pool thread is doing.
  • The error handler on the other side is a different error handler. Failures after a seda hop do not propagate back to the original consumer, so a dead letter channel configured on the first route will not catch them.

When are they right? When the decoupling is the point and the loss window is acceptable: fanning a message to a slow audit sink you do not want in the critical path, smoothing a bursty in-process producer, or deliberately staging work with different concurrency levels. Set bounds when you do: seda:audit?size=1000&blockWhenFull=true gives you a finite queue that pushes back instead of an unbounded one that turns a slow consumer into an OutOfMemoryError. Unbounded SEDA queues are a latent outage, not a performance feature.

If you need decoupling and durability, the answer is not a bigger SEDA queue. It is a real broker between the two halves.

parallelProcessing on splitters, multicasts, and recipient lists

.split(body().tokenize("\n"))
    .parallelProcessing()
    .executorService(splitPool)
    .to("http:downstream/api")

Here concurrency is within one exchange: sub-messages are processed on pool threads and aggregated when all complete. The consumer thread blocks until then, so back pressure survives — a nice property.

Two things to watch. First, sub-messages lose ordering unless you also handle it downstream. Second, and more important in practice: always supply your own executor for parallel splits. The default shared profile is fine for one route and a liability once three routes compete for the same pool, because they will starve each other at exactly the moment traffic is highest and it will look like a network problem. A named pool per busy route also means the thread names in a stack dump tell you which route is stuck, which is worth the configuration on its own.

Also resist the reflex to parallelize a split whose steps are cheap. For in-memory transformations, the handoff and aggregation overhead frequently exceeds the work; parallel splitting pays off when each sub-step blocks on I/O.

Sizing pools, and the number that actually matters

Thread pools in Camel come from profiles — the default profile, or named ones you define and reference by id. Sizing them is less about CPU counts than about the narrowest resource downstream. The question to ask in the design review is not "how many threads should this route have" but "how many concurrent calls can the thing at the end of this route absorb?" Size to that, and leave the rest of the pressure in the broker where it is durable, visible, and monitorable.

The pool's queue is where people get burned. A large task queue in front of a small pool makes throughput graphs look healthy while latency climbs into the minutes, because work is sitting in an invisible in-JVM queue rather than an observable broker. We prefer small queues and a rejection policy that pushes back, so overload shows up as a metric instead of a mystery.

Then measure. CallerRunsWhenRejected, pool exhaustion, and queue depth all surface through Camel's JMX and Micrometer instrumentation. Inflight exchanges per route, thread pool active count, and queue size are the three numbers that turn a threading argument into a threading answer.

The short version

Keep routes synchronous until you have a reason not to. Add concurrency at the consumer first, because it preserves transactions, error handling, and back pressure. Use seda and threads() only where you genuinely want decoupling and can afford an in-memory loss window, and always bound them. Give busy parallel splits their own named pool. Size everything to the slowest downstream resource rather than to the CPU count, and let a durable broker hold the backlog instead of the heap.

Threads are the easiest thing in Camel to add and the hardest to reason about afterwards. Spend the design review time up front.