Why Most Web Stacks Break Under Real Concurrency
You’ve probably hit this wall: your Node.js or Ruby on Rails app runs fine in staging, then traffic spikes hit and everything slows to a crawl. Thread pools fill up, memory climbs, and your ops team starts adding instances. The usual answer is “scale horizontally” — but that just papers over the actual problem.
The real culprit is the concurrency model. Most web frameworks share a thread pool across all requests. Under load, requests queue up waiting for a free thread. Past a few thousand simultaneous connections, you’re spending more CPU on context switching than on actual work.
Elixir takes a different approach. It runs on the BEAM virtual machine — the same runtime behind Erlang, which powers WhatsApp, Discord, and RabbitMQ. Instead of OS threads, the BEAM uses lightweight processes. We’re talking millions of them, each using as little as 1–2 KB of memory. Every request gets its own isolated process. No shared mutable state to lock, no thread pool to exhaust.
Phoenix, the web framework built on top of Elixir, inherits all of this. A well-known Phoenix benchmark demonstrated 2 million simultaneous WebSocket connections on a single server. I’ve run this in production on a 4-vCPU VPS — during a traffic spike that would have saturated a typical Rails or Express setup, response times barely moved.
Installation: Setting Up Elixir and Phoenix
Install Erlang and Elixir
Elixir requires Erlang/OTP underneath, so you need both. The cleanest way to manage them is asdf, a version manager that handles both runtimes without conflicts.
# Install asdf
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
source ~/.bashrc
# Add Erlang and Elixir plugins
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git
# Install dependencies (Ubuntu/Debian)
sudo apt-get install -y build-essential autoconf m4 libncurses5-dev \
libwxwidgets-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev \
libssh-dev unixodbc-dev xsltproc fop libxml2-utils
# Install specific versions
asdf install erlang 26.2.5
asdf install elixir 1.16.3-otp-26
asdf global erlang 26.2.5
asdf global elixir 1.16.3-otp-26
Verify the installation:
elixir --version
# Erlang/OTP 26 [erts-14.2.5] ...
# Elixir 1.16.3 (compiled with Erlang/OTP 26)
Install Phoenix and Create Your First App
Phoenix uses mix, Elixir’s built-in build tool — think of it as npm or Cargo, but for Elixir.
# Install the Phoenix application generator
mix archive.install hex phx_new
# Create a new Phoenix project
mix phx.new my_app --database postgres
cd my_app
This scaffolds a full Phoenix project with a PostgreSQL adapter. Skip the database entirely with --no-ecto if you’re just experimenting.
Phoenix 1.7+ ships with a standalone esbuild binary that downloads automatically — no Node.js required for basic asset compilation. Fetch dependencies and boot the server:
mix deps.get
mix ecto.create # Creates the database
mix phx.server # Starts the dev server
Hit http://localhost:4000 and you’ll see the Phoenix welcome page. File changes trigger live reloads automatically.
Configuration: Building for High Concurrency
Understanding the Process Model
Worth pausing here before touching any config knobs. Every HTTP request in Phoenix spawns a dedicated Elixir process. These aren’t OS threads — the BEAM scheduler manages them entirely in userspace. Your server can sustain hundreds of thousands of them simultaneously without the kernel being involved at all.
Production settings live in config/runtime.exs. This is evaluated at startup, not compile time, so environment variables work properly:
import Config
config :my_app, MyAppWeb.Endpoint,
http: [
port: String.to_integer(System.get_env("PORT") || "4000"),
# Increase acceptors for high traffic
transport_options: [num_acceptors: 100]
],
cache_static_manifest: "priv/static/cache_manifest.json"
Configuring Phoenix Channels for WebSocket Concurrency
Channels are where Elixir’s concurrency model really earns its keep. Each connected client runs in its own process — a chat room with 50,000 active users means 50,000 independent processes, each with its own isolated state. Here’s a minimal channel implementation:
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
def join("room:" <> room_id, _params, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
Wire it up in the socket module:
# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
def connect(_params, socket, _connect_info), do: {:ok, socket}
def id(_socket), do: nil
end
Database Connection Pooling with Ecto
The database becomes the bottleneck long before Elixir does. Ecto handles this via the DBConnection library, which maintains a pool of persistent database connections. Tune the pool in config/runtime.exs:
config :my_app, MyApp.Repo,
url: System.get_env("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20"),
queue_target: 5000, # ms before timing out waiting for connection
queue_interval: 1000 # interval for checking pool health
A reasonable starting point: set pool_size to vCPUs × 2–4. On a 4-core box, start at 10–16 and tune upward based on observed saturation. PostgreSQL has a hard connection limit (default 100) — don’t blow past it across multiple app instances.
Production Release Configuration
Mix releases package your application into a self-contained binary. The target server needs no Elixir installation — just copy the release artifact and run it.
# Build the release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
The release lands in _build/prod/rel/my_app/. Copy that directory to your server and start it:
PORT=4000 DATABASE_URL=postgres://... SECRET_KEY_BASE=... \
_build/prod/rel/my_app/bin/my_app start
For systemd, create /etc/systemd/system/my_app.service:
[Unit]
Description=MyApp Phoenix Server
After=network.target
[Service]
User=deploy
WorkingDirectory=/opt/my_app
EnvironmentFile=/opt/my_app/.env
ExecStart=/opt/my_app/bin/my_app start
ExecStop=/opt/my_app/bin/my_app stop
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Verification and Monitoring
Built-in Observability with LiveDashboard
Phoenix ships with Phoenix.LiveDashboard — a real-time monitoring UI showing process counts, memory usage, ETS table contents, and database query metrics. Add it to your router:
# lib/my_app_web/router.ex
if Mix.env() == :dev do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: MyAppWeb.Telemetry
end
end
Gate it behind authentication in production — it exposes internal system state you don’t want public.
Load Testing Your Setup
Run a load test before shipping. k6 works well for WebSocket-heavy apps and has a clean scripting API:
# Install k6 (see k6.io/docs/get-started/installation for current method)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6
# Run a simple HTTP load test
k6 run --vus 1000 --duration 30s script.js
A basic k6 script for HTTP testing:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 1000,
duration: '30s',
};
export default function () {
http.get('http://localhost:4000/api/health');
sleep(1);
}
BEAM-Level Process Monitoring
Most platforms require external APM tools to see what’s happening inside a running application. The BEAM lets you skip that. Connect directly to a live production node using IEx:
# Connect to the running release
_build/prod/rel/my_app/bin/my_app remote
From inside the IEx shell, query the live system:
# Count all running processes
length(Process.list())
# Check memory usage
:erlang.memory()
# See how many requests are currently being handled
:sys.get_state(MyApp.Repo)
# Check connected WebSocket channels
Phoenix.PubSub.list_subscriptions(MyApp.PubSub)
No agents, no sidecars. The runtime itself is inspectable — which makes debugging production issues considerably less painful.
What to Watch in Production
A few metrics worth tracking once your app is live:
- Process count: should stay stable. A count that keeps climbing indicates a process leak — somewhere you’re spawning processes that never exit.
- Message queue lengths: a process with a long message queue is a bottleneck. LiveDashboard surfaces this visually, so it’s easy to spot.
- Database pool saturation:
queue_targettimeouts in logs mean your pool is undersized or queries are too slow. Check both before bumpingpool_size. - Memory per node: BEAM memory usage is generally predictable. A sudden spike usually means a process is accumulating state — often a GenServer that grows its state map without bounds.
The BEAM’s “let it crash” philosophy pays dividends here. Supervisor trees restart failed processes automatically. Transient errors that would take down a Node.js process simply don’t propagate in Elixir — the supervisor restarts the affected process while the rest of the application keeps running.
If you’ve spent time fighting concurrency bugs in Go, Python, or Node.js, Elixir is genuinely worth the ramp-up. The actor model takes a week or two to click. After that, running a system where concurrency is a first-class concern of the runtime — not an afterthought layered on top — changes how you think about building servers.

