Performance Tuning
Felix trades between latency, throughput, and memory with explicit knobs. This page explains which knob moves which needle, gives three starting-point profiles, and points at the measured numbers. None of this has run in production. The configurations are benchmark-tested starting points, and your own measurements should override them.
Understanding Felix Performance
Section titled “Understanding Felix Performance”These factors decide Felix performance, and they interact:
- Network transport: QUIC connection and stream configuration
- Batching: Message aggregation at publish and delivery stages
- Parallelism: Connection pools and worker threads
- Buffering: Queue depths and flow control windows
- Encoding: Binary wire framing efficiency
- Outbound lanes: Subscriber writer lane count and lane sharding policy
Performance Profiles
Section titled “Performance Profiles”Felix has three preset profiles to start from. They are the same pipeline with one setting changed: how long a message may wait for others to batch with before it is sent.
flowchart LR
subgraph lat["Latency-optimized"]
direction TB
L1(["message"]) l1@--> L2["send immediately<br/><small>batch ≈ 1, shallow queues,<br/>block instead of drop</small>"]
L2 l2@--> L3(["lowest p99<br/><small>fewest messages per syscall</small>"])
end
subgraph bal["Balanced (default)"]
direction TB
B1(["message"]) b1@--> B2["brief coalescing window<br/><small>moderate batching + pools</small>"]
B2 b2@--> B3(["sub-ms latency at<br/>useful throughput"])
end
subgraph thr["Throughput-optimized"]
direction TB
T1(["message"]) t1@--> T2["fill the batch<br/><small>deep queues, large windows,<br/>lossless pacing</small>"]
T2 t2@--> T3(["most bytes per second<br/><small>latency includes batch fill</small>"])
end
l1@{ animation: fast }
l2@{ animation: fast }
b1@{ animate: true }
b2@{ animate: true }
t1@{ animation: slow }
t2@{ animation: slow }
classDef step fill:#e8f0fe,stroke:#4a6fa5,color:#1a2b40
classDef ok fill:#e9f5ec,stroke:#4a8a5e,color:#16301f
classDef warm fill:#fdf0e3,stroke:#b07d3a,color:#3d2a12
class L2,B2,T2 step
class L1,B1,T1 ok
class L3,B3 ok
class T3 warm
Balanced Profile (Default)
Section titled “Balanced Profile (Default)”General-purpose settings for mixed workloads:
Broker configuration:
# Connection poolspub_conn_pool: 4pub_streams_per_conn: 2event_conn_pool: 8cache_conn_pool: 8cache_streams_per_conn: 4
# QUIC flow controlevent_conn_recv_window: 268435456 # 256 MiBevent_stream_recv_window: 67108864 # 64 MiBevent_send_window: 268435456 # 256 MiB
# Batchingevent_batch_max_events: 64event_batch_max_bytes: 65536event_batch_max_delay_us: 250fanout_batch_size: 64
# Queue depths (bounded, so overload shows up in counters rather than an unbounded backlog)pub_queue_depth: 512pub_inflight_bytes: 67108864 # 64 MiB shared in-flight publish byte budgetsubscriber_queue_capacity: 512subscriber_writer_lanes: 4subscriber_lane_queue_depth: 64max_subscriber_writer_lanes: 8subscriber_lane_shard: auto
publish_chunk_bytes: 16384Expected performance: see Benchmarks for current, measured
numbers across payload/fanout shapes. The harness runs against this profile. Numbers here are intentionally not duplicated to avoid
drift; the benchmarks page is regenerated from latency-demo and is the
source of truth.
Best for:
- Mixed pub/sub, cache and consumer-group workloads
- Moderate fanout (1-20 subscribers)
- General application development
- Starting point for tuning
Latency-Optimized Profile
Section titled “Latency-Optimized Profile”Minimize tail latency at the cost of throughput:
Broker configuration:
# Smaller poolspub_conn_pool: 2pub_streams_per_conn: 1event_conn_pool: 4
# Smaller windowsevent_conn_recv_window: 67108864 # 64 MiBevent_stream_recv_window: 16777216 # 16 MiBevent_send_window: 67108864 # 64 MiB
# Minimal batchingevent_batch_max_events: 8event_batch_max_bytes: 32768event_batch_max_delay_us: 100fanout_batch_size: 8
# Fast acknowledgementsack_on_commit: true
# Shallow queues, blocking backpressure instead of drops, single writer per# connection for stable per-message orderingpub_queue_depth: 32subscriber_queue_capacity: 64subscriber_queue_policy: blocksubscriber_writer_lanes: 2subscriber_lane_queue_depth: 32subscriber_lane_queue_policy: blocksubscriber_single_writer_per_conn: truesubscriber_flush_max_items: 1subscriber_flush_max_delay_us: 0subscriber_lane_shard: autoExpected performance: the latency-focused profile in Benchmarks (batch = 1, per-message acked) measures this shape directly: sub-millisecond p999 at fanout 1-10 on the reference hardware there.
Best for:
- Real-time interactive applications
- Trading systems, gaming
- Sensor data with immediate processing
- Low fanout (1-5 subscribers)
Throughput-Optimized Profile
Section titled “Throughput-Optimized Profile”Maximize throughput and burst tolerance:
Broker configuration:
# Large poolspub_conn_pool: 8pub_streams_per_conn: 4event_conn_pool: 16cache_conn_pool: 16cache_streams_per_conn: 8
# Large windowsevent_conn_recv_window: 536870912 # 512 MiBevent_stream_recv_window: 134217728 # 128 MiBevent_send_window: 536870912 # 512 MiB
# Aggressive batchingevent_batch_max_events: 256event_batch_max_bytes: 1048576event_batch_max_delay_us: 2000fanout_batch_size: 256
# Acknowledge on accept, before the write: faster, but an acknowledged record# can be lost if the broker crashes. Durable streams ack after commit without it.ack_on_commit: false
# Deep queues, lossless end-to-end backpressure (paces the publisher to the# pipeline's sustainable rate instead of shedding), thread-per-core stream# ownership for multi-stream workloadspub_queue_depth: 2048pub_inflight_bytes: 268435456 # 256 MiBpub_ingress_wait: truesubscriber_queue_capacity: 4096subscriber_queue_policy: blocksubscriber_writer_lanes: 8subscriber_lane_queue_depth: 1024subscriber_lane_queue_policy: blockmax_subscriber_writer_lanes: 8subscriber_lane_shard: autocore_shards: 4 # tune to (physical cores - 2); 0 = off
publish_chunk_bytes: 32768Expected performance: the throughput-focused profile in
Benchmarks (batch = 64, lossless, zero drops)
measures this shape directly. Message rate falls and byte rate rises as
payloads grow, so read the byte rate when comparing payload sizes.
core_shards may help multi-stream workloads, but its published gains predate
the transport scheduling work and need re-validation.
Best for:
- High-throughput data pipelines
- Log aggregation, metrics collection
- High fanout (20-100+ subscribers)
- Batch processing workflows
Configuration Parameters
Section titled “Configuration Parameters”Pub/Sub Parameters
Section titled “Pub/Sub Parameters”Connection Pooling
Section titled “Connection Pooling”event_conn_pool: 8 # QUIC connections for eventspub_conn_pool: 4 # QUIC connections for publishingpub_streams_per_conn: 2 # Publish streams per connectionTuning guidance:
| Workload | event_conn_pool | pub_conn_pool | streams_per_conn |
|---|---|---|---|
| Light | 2-4 | 2 | 1-2 |
| Medium | 4-8 | 2-4 | 2 |
| Heavy | 8-16 | 4-8 | 2-4 |
| Very heavy | 16-32 | 8-16 | 4-8 |
Flow Control Windows
Section titled “Flow Control Windows”event_conn_recv_window: 268435456 # Per-connection receive windowevent_stream_recv_window: 67108864 # Per-stream receive windowevent_send_window: 268435456 # Per-connection send windowMemory impact calculation:
Worst-case memory = (conn_window × conn_pool) + (stream_window × avg_streams × conn_pool)Example:
conn_pool=8,conn_window=256MB,stream_window=64MB,avg_streams=10- Memory ≈ (256MB × 8) + (64MB × 10 × 8) = 2GB + 5.1GB = 7.1GB
Tuning guidance:
- Low latency, limited bursts: Use smaller windows (64-128 MiB)
- High throughput, bursty: Use larger windows (256-512 MiB)
- Memory constrained: Reduce pool size before reducing windows
These are the client’s windows. On the broker side, pub_conn_recv_window and
pub_stream_recv_window bound how much unread publish data one client
connection can leave in the broker. They default to pub_conn_inflight_bytes
(16 MiB), so backpressure reaches a publisher soon after ingress fills.
Batching Parameters
Section titled “Batching Parameters”event_batch_max_events: 64 # Max events per batchevent_batch_max_bytes: 262144 # Max batch size (256 KB)event_batch_max_delay_us: 250 # Max batching delay under load (250 µs)fanout_batch_size: 64 # Fanout batch sizeBatch triggers: Event batch is sent when any condition is met. The delay applies only under load: a batch that finds nothing else queued is sent at once.
Trade-off analysis:
| Parameter | ↑ Increase Effect | ↓ Decrease Effect |
|---|---|---|
max_events |
Higher throughput, higher latency | Lower latency, lower throughput |
max_delay_us |
Higher throughput, higher latency under load | Lower latency under load, lower throughput |
max_bytes |
Fewer frames, more efficiency | More frames, less efficiency |
fanout_batch_size |
Better fanout efficiency | Lower fanout latency |
Recommended settings by workload:
| Workload | event_batch_max_events |
event_batch_max_delay_us |
|---|---|---|
| Latency first | 4 | 50 |
| Low latency | 8 | 100 |
| Balanced (default) | 64 | 250 |
| High throughput | 128 | 1000 |
| Maximum throughput | 256 | 2000 |
For example, the high-throughput profile as a config file:
event_batch_max_events: 128event_batch_max_delay_us: 1000Queue Depths and Byte Budgets
Section titled “Queue Depths and Byte Budgets”pub_queue_depth: 512 # Publish queue slots per executor (items)pub_inflight_bytes: 67108864 # Shared in-flight publish byte budget (bytes, not items)subscriber_queue_capacity: 512 # Per-subscriber broker-core queuesubscriber_lane_queue_depth: 64 # Per-lane outbound writer queuepub_workers_per_conn: 4 # Publish workers per connection (ignored when core_shards > 0)Design intent: defaults are bounded, not deep. pub_queue_depth and the
lane queues bound how much can queue before backpressure or shedding kicks
in, so overload shows up in drops and counters instead of an unbounded
backlog. The publish queue (pub_queue_depth × pub_workers_per_conn, 2048
jobs by default) is sized to cover the publishes in flight from many
pipelining publishers: when it fills, the publishing connection stops being
read and all its streams stall. pub_inflight_bytes is a second, independent
budget on bytes rather than item count, so a few large batches can’t blow
past the ingress memory budget even with a small pub_queue_depth.
- Shallower (production default direction): lower memory, backpressure/drops surface sooner, bounded tail latency.
- Deeper (opt-in, throughput profile): higher burst tolerance and memory, and only safe paired with
subscriber_queue_policy: block+pub_ingress_wait: true(lossless pacing). Without those, deep queues only delay the drops.
Memory per queue:
Queue memory ≈ queue_depth × avg_message_size
Example (default subscriber_queue_capacity=512): 512 × 4KB = 2MB per subscriber queueWith 100 subscribers: 100 × 2MB = 200MBOutbound Writer Lanes and Sharding
Section titled “Outbound Writer Lanes and Sharding”Writer lanes parallelize outbound subscriber writes while preserving per-subscriber ordering.
subscriber_writer_lanes: 4max_subscriber_writer_lanes: 8subscriber_lane_queue_depth: 64subscriber_lane_shard: auto # auto | subscriber_id_hash | connection_id_hash | round_robin_pinStart here:
subscriber_lane_shard: autosubscriber_writer_lanes: 4- Increase to
8only if throughput is still lane-bound - Avoid assuming larger lane counts always help; watch p99/p999
- For multi-stream workloads, also evaluate
core_shards(thread-per-core stream ownership). See Benchmarks, which showed larger gains there than lane count alone.
Cache Parameters
Section titled “Cache Parameters”cache_conn_pool: 8 # QUIC connections for cachecache_streams_per_conn: 4 # Streams per connectioncache_conn_recv_window: 268435456 # 256 MiB per connectioncache_stream_recv_window: 67108864 # 64 MiB per streamConcurrency calculation:
Max concurrent cache ops = cache_conn_pool × cache_streams_per_connRecommended by workload:
| Workload | conn_pool | streams_per_conn | Max Concurrency |
|---|---|---|---|
| Low | 4 | 2 | 8 |
| Medium | 8 | 4 | 32 |
| High | 16 | 8 | 128 |
| Very high | 32 | 16 | 512 |
Event Frame Encoding
Section titled “Event Frame Encoding”Subscription event delivery uses binary EventBatch framing by default.
Benchmark Results
Section titled “Benchmark Results”Pub/Sub Latency and Throughput
Section titled “Pub/Sub Latency and Throughput”See Benchmarks for current, methodology-documented
results: latency and throughput profiles across payload sizes and fanout,
the transport levers behind them (MTU/GSO, congestion window, socket
buffers), the core_shards thread-per-core lever, and how to regenerate the
numbers yourself with latency-demo. That page is generated from the same
harness referenced throughout this guide and is kept current; numbers are
intentionally not duplicated here to avoid the two pages drifting apart.
Cache Performance (Localhost)
Section titled “Cache Performance (Localhost)”Configuration: 8 connections, 4 streams/conn, concurrency=32
| Operation | Payload | p50 | p99 | Throughput |
|---|---|---|---|---|
| put | 0 B | 158 µs | 350 µs | 184k ops/sec |
| put | 256 B | 179 µs | 380 µs | 155k ops/sec |
| put | 4 KB | 260 µs | 480 µs | 78k ops/sec |
| get (hit) | 256 B | 177 µs | 360 µs | 166k ops/sec |
| get (miss) | - | 165 µs | 340 µs | 179k ops/sec |
Profiling and Diagnostics
Section titled “Profiling and Diagnostics”Telemetry Feature
Section titled “Telemetry Feature”Enable detailed performance telemetry:
[dependencies]felix-client = { version = "0.1", features = ["telemetry"] }felix-broker = { version = "0.1", features = ["telemetry"] }# Broker configdisable_timings: false # Enable timing measurementsMetrics collected:
- Per-operation latency histograms (publish, subscribe, cache)
- Frame counters (publish frames, event frames, cache frames)
- Queue depth samples
- Flow control events
Overhead: 5-15% throughput reduction in high-load scenarios.
Performance Debugging
Section titled “Performance Debugging”High publish latency:
- Check
pub_queue_depthandpub_inflight_bytes- is the queue or byte budget filling up? - Check
pub_workers_per_conn(orcore_shardsif enabled) - enough parallelism? - Check broker CPU usage - saturated?
- Enable telemetry - where is time spent?
- Check
felix_broker_ingress_dropped_total/felix_broker_ingress_rejected_total- is ingress shedding underpub_ingress_wait: false?
High subscribe latency:
- Check
subscriber_queue_capacity,subscriber_queue_policy, and lane drop counters - subscribers falling behind? - Check
event_batch_max_delay_us- batches waiting too long under load? - Check QUIC flow control - windows exhausted?
- Check subscriber processing time - bottleneck in application?
- Check path MTU discovery (
FELIX_MTU_UPPER_BOUND) - see Benchmarks for why this matters more than it looks.
Low throughput:
- Increase
event_batch_max_events- more aggressive batching - Increase connection pools - more parallelism
- Confirm binary
EventBatchdecoding path in subscribers - Check network bandwidth - saturated?
- Increase
pub_workers_per_conn- more publish parallelism
High memory usage:
- Reduce flow control windows
- Reduce queue depths
- Reduce connection pool sizes
- Check for slow subscribers - filling buffers?
Production Recommendations
Section titled “Production Recommendations”Sizing Guidelines
Section titled “Sizing Guidelines”Small deployment:
pub_conn_pool: 2event_conn_pool: 4cache_conn_pool: 4event_batch_max_events: 32pub_queue_depth: 128subscriber_queue_capacity: 64subscriber_writer_lanes: 2The publish queue should cover the publishes in flight from your publishers, so it grows with the number of pipelining connections.
Expected resources: 2 CPU cores, 2-4 GB RAM
Medium deployment:
pub_conn_pool: 4event_conn_pool: 8cache_conn_pool: 8event_batch_max_events: 64pub_queue_depth: 512subscriber_queue_capacity: 512subscriber_writer_lanes: 4Expected resources: 4-8 CPU cores, 4-8 GB RAM
Large deployment (multi-stream, high fanout):
pub_conn_pool: 8event_conn_pool: 16cache_conn_pool: 16event_batch_max_events: 128pub_queue_depth: 2048pub_inflight_bytes: 268435456subscriber_queue_capacity: 4096subscriber_writer_lanes: 8core_shards: 4 # tune to (physical cores - 2)Expected resources: 16-32 CPU cores, 16-32 GB RAM
Tuning Workflow
Section titled “Tuning Workflow”- Start with balanced profile: Use defaults
- Measure baseline: Run realistic workload, measure latency/throughput
- Identify bottleneck: CPU? Memory? Network? Queue depths?
- Tune one parameter: Change single parameter
- Re-measure: Verify improvement
- Iterate: Repeat until requirements met
Monitoring in Production
Section titled “Monitoring in Production”Key metrics to track:
- Publish rate and latency (p50, p99, p999)
- Subscribe rate and latency
- Queue depths (publish, event)
- Lane queue pressure (per-lane enqueue/drop/highwater)
- Connection count
- CPU and memory usage
- Network bandwidth
- Dropped event count
- Slow subscriber count
Alert against your own measured baseline (say, p99 above twice it) rather than absolute numbers, because useful thresholds depend on the workload.
Hardware Recommendations
Section titled “Hardware Recommendations”- Minimum: 2 cores
- Recommended: 4-8 cores for medium workloads
- High performance: 16-32 cores for high throughput
Felix is CPU-bound for:
- QUIC encryption (TLS 1.3 AEAD, always on)
- Wire encoding/decoding: binary by default for unacknowledged publishes
and always for event delivery; JSON only for acked publishes and explicit
publish_json/publish_batch_jsoncalls - Fanout: encoding happens once per publish batch and the encoded frame is
shared across subscribers (not re-encoded per subscriber), so this scales
with publish rate rather than
publish rate × fanout
Memory
Section titled “Memory”- Minimum: 2 GB
- Recommended: 4-8 GB for medium workloads
- High performance: 16-32 GB for high throughput with large queues
Memory usage scales with:
- Connection pool sizes × flow control windows
- Queue depths × subscriber count
- Cache size
Network
Section titled “Network”- Minimum: 1 Gbps
- Recommended: 10 Gbps for high throughput
- Ideal: 25+ Gbps for very high throughput
QUIC benefits from:
- Low latency networks (< 1 ms RTT)
- High bandwidth
- Low packet loss (< 0.1%)
- Ephemeral streams: not used, so there is no disk I/O on the hot path
- Durable streams: NVMe SSD strongly recommended. Under
fsync_mode = on_commiteach commit costs one device flush (~4ms on a typical NVMe), which group commit amortises across concurrent publishers; underperiodicthe flush is off the append path entirely. Measured figures and a regression budget are in storage-performance.md.
Summary
Section titled “Summary”Start from the balanced profile, run a realistic workload, and change one knob at a time off a measurement. Queue depths tell you where pressure is; batching buys throughput at the price of per-message latency; pools buy isolation at the price of memory. Leave telemetry off in production, keep 2–3× headroom above expected load, and write down what you changed and what it measured.
