
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
When your Elixir application handles traffic spikes but response times degrade unpredictably, the bottleneck is rarely the language itself; it is usually a misconfigured runtime or an unobserved resource constraint. Effective performance tuning Elixir in production demands moving beyond default configurations to align the BEAM virtual machine, database connection pools, and garbage collection strategies with your specific workload profile. This guide provides the concrete configuration changes and observability hooks required to stabilize latency and maximize throughput.
How do you configure BEAM schedulers for optimal throughput?
The most common mistake I see when teams first deploy Elixir to Kubernetes or cloud VMs is leaving the Erlang VM (BEAM) on its default scheduler settings. The BEAM uses preemptive schedulers to manage lightweight processes, and by default, it creates one online scheduler per logical CPU core detected at boot. In containerized environments like Docker or Kubernetes, this detection can be inaccurate if cgroups are not properly respected, leading to massive context-switching overhead that destroys latency percentiles.
You must explicitly set the number of online schedulers using the +S flag in your vm.args file. If your container has a CPU limit of 4 cores, set +S 4:4. The first number is online schedulers, the second is total schedulers. Keeping them equal prevents the VM from waking up offline schedulers unnecessarily. For workloads involving heavy NIFs (Native Implemented Functions) or blocking I/O, you should also tune dirty schedulers separately using +SDcpu and +SDio. A good starting point for dirty CPU schedulers is equal to your online scheduler count, while dirty IO schedulers can often be left at the default unless you are doing massive file operations.
# config/vm.args.eex
## Set schedulers to match container CPU limit
+S ${ELIXIR_SCHEDULERS:-4}:${ELIXIR_SCHEDULERS:-4}
## Dirty schedulers for NIFs and blocking IO
+SDcpu ${ELIXIR_DIRTY_CPU:-4}
+SDio 10
## Enable SMP automatically
-smp auto
## Set async threads for port drivers
+A 64 Always verify these settings at runtime. Connect to your running node via iex -S mix release remote and run :erlang.system_info(:schedulers_online). If this number exceeds your cgroup CPU quota, you are over-subscribed and will experience tail latency spikes due to OS-level preemption. This verification step is non-negotiable for reliable Kubernetes resource management.
How should you size Ecto connection pools for high concurrency?
Database connections are typically the scarcest resource in an Elixir stack. A frequent failure mode during performance tuning Elixir in production is setting the Ecto pool size too high, overwhelming the database, or too low, causing request queuing within the application. The optimal pool size is not a function of how many requests your app receives, but rather how many concurrent queries your database can efficiently execute.
Start with the formula: pool_size = (core_count * 2) + effective_spindle_count for traditional databases, or simply core_count * 4 as a baseline for modern cloud databases like Aurora or Cloud SQL. However, you must validate this against actual wait times. Monitor ecto.queue_time; if it consistently exceeds 10ms, your pool is too small. If your database CPU is saturated while your Elixir nodes are idle, your pool is too large.
| Metric | Healthy Range | Bottleneck Indicator | Tuning Action |
|---|---|---|---|
| ecto.queue_time | < 5ms | > 50ms sustained | Increase pool_size or optimize queries |
| db.query_time | < 20ms (p95) | > 100ms p95 | Add indexes, check DB CPU/RAM |
| Pool Checkout Time | < 1ms | Sporadic spikes > 100ms | Check for connection leaks or long transactions |
| Active Connections | 60-80% of pool | Consistently 100% | Increase pool or add read replicas |
Remember that each Elixir node maintains its own pool. If you have 10 nodes with a pool size of 20, that is 200 connections hitting your database simultaneously. Always calculate total connections across your entire cluster. For detailed database-specific guidance, refer to our PostgreSQL administration essentials guide which covers connection management patterns applicable to Ecto backends.
What telemetry metrics reveal hidden BEAM bottlenecks?
You cannot tune what you cannot measure. Relying solely on HTTP response codes or average latency masks the internal dynamics of the BEAM that cause intermittent failures. Effective performance tuning Elixir in production requires exposing internal VM metrics through OpenTelemetry. You need visibility into process mailboxes, memory fragmentation, and garbage collection pressure.
Critical metrics to export include vm.memory.total, vm.memory.processes_used, and vm.total_run_queue_lengths.cpu. The run queue length is your primary indicator of CPU saturation within the BEAM; if this value consistently exceeds your scheduler count, your processes are waiting for CPU time and latency will climb. Additionally, track vm.message_queue_len for critical processes like GenServers handling state; a growing queue indicates the process cannot keep up with incoming messages, often pointing to inefficient logic or blocking operations inside the handle_info/handle_cast callbacks.
# lib/my_app/telemetry.ex
defp metrics do
[
# VM Metrics
last_value("vm.memory.total", unit: :byte),
last_value("vm.memory.processes_used", unit: :byte),
last_value("vm.total_run_queue_lengths.cpu"),
last_value("vm.total_run_queue_lengths.io"),
# Ecto Metrics
summary("repo.query.total_time",
unit: {:native, :millisecond},
tags: [:source, :command]
),
summary("repo.query.queue_time",
unit: {:native, :millisecond}
)
]
end For comprehensive tracing setup, follow our guide to instrumenting apps with OpenTelemetry, which covers the specific OTP library versions and exporter configurations needed for 2026 Elixir releases.
How does garbage collection strategy affect tail latency?
Erlang's per-process garbage collection is generally excellent, but default heap growth rates can cause latency spikes in memory-intensive workloads. When a process heap grows too aggressively, it triggers major GC cycles that pause execution. For services processing large payloads or maintaining significant state, tuning heap parameters reduces p99 latency variance.
The key flags are +hms (minimum heap size) and +hmbs (minimum binary virtual heap size). Increasing the minimum heap size for worker processes reduces the frequency of early-stage GC cycles. Use Process.flag(:min_heap_size, words) selectively for known heavy processes rather than globally, as oversized heaps waste memory for the majority of short-lived processes. Also consider enabling the fullsweep_after option cautiously; setting it to 0 forces full sweeps more frequently, which increases CPU usage but prevents memory fragmentation in long-lived processes.
Monitor vm.gc.collections and vm.gc.bytes_reclaimed alongside latency. If GC frequency correlates with latency spikes, you have identified a tuning target. Remember that binary data lives outside the process heap in a separate allocator; excessive binary creation requires tuning the binary allocator via +MBas and related flags, not just process heap settings.
When should you adjust async thread pool and port parallelism?
Elixir excels at concurrency, but operations that interact with the outside world through ports or NIFs can block schedulers. The async thread pool (+A) handles certain driver operations, and undersizing it creates a hidden serialization point. Default values were reasonable for 2015 hardware; modern systems with fast NVMe storage and high-bandwidth networking benefit from larger pools.
Set +A to at least 64 for web applications, and higher if you perform significant file I/O or use crypto operations extensively. Similarly, review port parallelism. Each port has a lock; if multiple processes communicate heavily with the same port (like a database driver or external command), they serialize. Distribute load across multiple port instances where possible, and ensure your NIFs are marked as dirty if they exceed 1ms of execution time to avoid blocking normal schedulers.
Sustaining Performance Through Continuous Validation
Configuration is not a one-time event. As your application evolves and traffic patterns shift, previously optimal settings become liabilities. Establish a quarterly review cycle where you compare current telemetry baselines against your configured thresholds. Load test with realistic traffic profiles that include your worst-case payload sizes and query patterns, not just synthetic benchmarks. Integrate load testing with k6 into your CI pipeline to catch regressions before deployment.
Document every tuning decision with the rationale and the metric that triggered it. Future engineers—including yourself six months from now—need to understand why schedulers are set to 8 or why the pool size is 25. Without this context, well-intentioned "cleanup" efforts revert critical optimizations and reintroduce problems you already solved. If your team needs assistance establishing these baselines or auditing an existing Elixir deployment, reach out to discuss your infrastructure.