Performance Tuning Elixir in Production

Khimananda Oli 8 min read Programming and Languages
Performance Tuning Elixir in Production

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.

BEAM Scheduler ArchitectureScheduler 1Online (Bound)Scheduler 2Online (Bound)Scheduler NDirty CPUScheduler IODirty IOLinux Kernel / Cgroup CPU QuotaCorrect: +S 4:4Matches Container LimitWrong: +S 32:32Host Core Count Detected
BEAM scheduler alignment with container CPU limits prevents context switching thrashing during performance tuning Elixir in production.

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.

MetricHealthy RangeBottleneck IndicatorTuning Action
ecto.queue_time< 5ms> 50ms sustainedIncrease pool_size or optimize queries
db.query_time< 20ms (p95)> 100ms p95Add indexes, check DB CPU/RAM
Pool Checkout Time< 1msSporadic spikes > 100msCheck for connection leaks or long transactions
Active Connections60-80% of poolConsistently 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.

Elixir Observability PipelineBEAM VMSchedulers, MemoryProcess CountsApplicationEcto, PhoenixCustom EventsOpenTelemetryBatch ProcessorOTLP ExporterPrometheus / GrafanaDashboards & AlertsTempo / JaegerDistributed Traces
Telemetry flow from BEAM internals to observability backends enables data-driven performance tuning Elixir in production.

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.

Before: Blocked SchedulersAfter: Optimized ThreadingScheduler 1: BLOCKED (NIF 50ms)Scheduler 2: Waiting for Port LockScheduler 3: Starved Process QueueResult: p99 Latency > 500msScheduler 1: Normal ProcessesDirty CPU: NIF OffloadedAsync Thread: Port I/O ParallelResult: p99 Latency < 20msDefault ConfigTuned +SDcpu +A 64
Impact of proper thread pool and dirty scheduler configuration on latency during performance tuning Elixir in production.

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.

Frequently Asked Questions

Use eprof or benchee for micro-benchmarks and :observer_cli for live system inspection. For production tracing, attach Recon or Wobserver to inspect process memory, message queue lengths, and reduction counts without stopping the running BEAM instance safely.

Enable +S with scheduler count matching physical cores and set +P to increase process limits beyond default 262144. Configure async thread pool size via +A for NIFs and enable dirty schedulers with +SDcpu for blocking C code operations in 2026 OTP releases.

No. Oversized pools cause context switching overhead and memory bloat. Benchmark with poolboy or nimble_pool using realistic load patterns. Optimal size typically equals CPU cores times two for IO-bound workloads, but validate with telemetry metrics under actual production traffic conditions.

Elixir matches Go for concurrent IO-bound workloads but trails by thirty percent in pure computation. Elixir excels at fault tolerance and hot code upgrades while Go offers lower memory per goroutine. Choose based on team expertise and operational requirements rather than synthetic benchmarks alone.

Slow consumers, unbounded mailboxes, or synchronous calls blocking handlers cause accumulation. Monitor with :erlang.process_info(pid, :message_queue_len). Fix by implementing backpressure, using GenStage demand-driven flows, or offloading heavy work to separate worker processes with proper supervision strategies.

Tune generational GC via erl_gc_options environment variable. Increase young generation heap size to reduce minor collections. Use binary reference counting for large payloads. Profile with recon_trace to identify processes allocating excessively and refactor to reuse binaries or stream data instead of accumulating lists.

Yes. Telemetry provides real-time metrics on request latency, database query times, and VM internals. Attach handlers to emit events to Prometheus or Datadog. Without observability you cannot distinguish between network delays, slow queries, or BEAM scheduler saturation during performance degradation incidents.

Tuning itself costs engineering time only. Optimized Elixir apps typically reduce cloud spend twenty to forty percent by improving throughput per node. Budget three to five senior developer weeks for comprehensive profiling, benchmarking, and configuration validation across staging and production environments in 2026.

Exposed observer_cli or Wobserver endpoints leak process state, memory contents, and allow remote code execution. Never enable in public-facing containers. Restrict access via VPN or SSH tunnels. Disable epmd clustering ports externally and use TLS distribution for node communication in multi-datacenter deployments.

Set adapter options for cowboy or bandit including max_connections and protocol options. Enable compression for responses over one kilobyte. Configure plug pipeline order placing authentication before expensive operations. Use telemetry_span to measure each plug duration and eliminate bottlenecks in request handling chains.

Use ETS for local read-heavy caches with simple key-value lookups requiring microsecond latency. Choose Mnesia for distributed replication, transactions, or complex queries across nodes. ETS has zero coordination overhead but no persistence. Mnesia adds consensus latency suitable for shared state requiring consistency guarantees.

Use Ecto.Adapters.SQL.explain to analyze query plans before optimizing. Wrap queries in Repo.transaction with telemetry events capturing decode and query durations separately. Test with production-scale datasets not fixtures. Index columns used in where clauses and avoid loading associations unless explicitly required by business logic.

Binary matching creates sub-binaries referencing original heap data avoiding copies. However, holding references prevents garbage collection of large source binaries. Use binary copy explicitly when extracting small segments from large payloads to allow source reclamation and prevent memory retention issues in long-lived processes.

Set MIX_ENV=prod and enable strip_beams in rel/config.exs to remove debug info reducing memory footprint. Configure vm.args.emulator_flags for JIT compilation in OTP 27+. Include runtime.exs for environment-specific tuning. Precompile protocols and consolidate implementations to eliminate runtime dispatch overhead during boot sequences.

P99 spikes indicate tail latency from GC pauses, lock contention, or external service timeouts. Check scheduler utilization asymmetry with recon:scheduler_usage. Inspect ets table fragmentation and enable write_concurrency. Profile specific slow traces using OpenTelemetry sampling to capture rare path execution costs invisible in aggregate averages.