Stampede

Hex version CI Docs License

Load testing for Phoenix that speaks Phoenix: Channels, LiveView, HTTP and plain WebSockets, with scenarios written in Elixir.

The dashboard following a run as it ramps up to 400 users and back down

Why I built this

I wanted to see how a real-time Phoenix app held up with hundreds of people in one channel. Generic load testers see a channel as just another WebSocket, so the scripts end up building phx_join messages, refs and heartbeats by hand. Chaperon, the Elixir load tester I found, hasn't had a release since 2020. And "how do I load test LiveView?" keeps coming up on Elixir Forum without a good answer.

So I wrote the tool I wanted. Stampede already knows the Channels protocol and how a LiveView session starts, so a scenario is just what a user does. Every virtual user (VU) is a BEAM process, which means one laptop can hold thousands of connections, and when it can't, more machines can join over Erlang distribution. Scenarios are plain Elixir modules, so they can call your own code.

Installation

The quickest way is Igniter, which also drops a first scenario into loadtest/home.exs:

$ mix igniter.install stampede

Without Igniter, add it to your dependencies:

def deps do
  [
    {:stampede, "~> 1.2", only: [:dev, :test]}
  ]
end

You'll need Elixir 1.18 or newer.

Your first load test

mix stampede.gen.scenario Lobby --channels gives you something to start from. There's also --live and --websocket, and no flag gets you plain HTTP. Written by hand, a channels scenario looks like this:

defmodule MyApp.Load.Lobby do
  use Stampede.Scenario

  @impl true
  def options do
    [
      stages: [{[second: 30], 500}, {[minute: 1], 500}, {[second: 10], 0}],
      thresholds: [{"channel.join", p95: 250}, {"iteration.errors", count: 0}]
    ]
  end

  @impl true
  def run(vu) do
    {:ok, socket} = Stampede.Channels.connect("ws://localhost:4000/socket")
    {:ok, _reply} = Stampede.Channels.join(socket, "room:lobby")

    for _ <- 1..10 do
      {:ok, _reply} = Stampede.Channels.push(socket, "room:lobby", "new_msg", %{body: "hi from #{vu.id}"})
      Process.sleep(1_000)
    end
  end
end

Save it as loadtest/lobby.exs and run it:

$ mix stampede.run loadtest/lobby.exs

That ramps up to 500 VUs over 30 seconds, holds them for a minute and ramps back down, with a progress line every second. Each VU calls run/1 over and over. At the end you get a summary. This one is from a run with 6,000 VUs across three nodes, all on my laptop along with the Phoenix app:

Demo.Chat ran for 15.0s

  metric          count   rate/s     mean     p50      p90      p95      p99       max
  channel.join     6000   399.89  10.72ms  5.49ms  18.59ms  31.90ms  97.77ms  103.81ms
  channel.push   139348  9287.39   2.56ms   854µs   6.84ms  11.27ms  22.25ms   55.84ms
  ws.connect       6000   399.89  16.28ms 11.50ms  28.29ms  35.25ms 134.64ms  209.06ms

  thresholds
    pass  channel.join p95 <= 500.00ms (31.90ms)
    pass  iteration.errors count <= 0 (0)

If a threshold fails, the task exits with status 1, so it works in CI as it is.

Watching a run

Add --dashboard and open http://127.0.0.1:4040:

$ mix stampede.run loadtest/lobby.exs --dashboard

That's the page in the animation at the top. It updates every second while the test runs, and shows the full results when it's done.

How many users can it handle?

That's usually the real question, so Stampede can answer it directly:

$ mix stampede.run loadtest/lobby.exs --find-limit

Instead of a fixed load, it adds users a step at a time and checks your thresholds over each step. The first step that breaks one ends the run, and the summary tells you the most users that passed.

I tried it on a fresh Phoenix app, running in production mode, with a LiveView page where every user loads the page, joins, clicks and stays for about 20 seconds. The limit was a p95 under 500ms for joins and under 100ms for clicks, with no errors:

$ mix stampede.run loadtest/counter.exs --find-limit --start 1000 --step 1000 --up-to 12000 --hold 20s
  limit
    Held 12,000 VUs, the most it was asked to try

  VUs     live.join p95  live.event p95  iteration.errors count  status
  1,000          2.67ms          1.41ms                       0    pass
  4,000          3.46ms          1.83ms                       0    pass
  8,000          6.57ms          5.60ms                       0    pass
  12,000        11.97ms         11.05ms                       0    pass

So with the app and the load test sharing my laptop, Phoenix held 12,000 LiveView users with joins under 12ms at p95 and not a single error, and Stampede never found where it breaks.

Then I moved it to AWS: stock Phoenix on one 4-core, 32 GB server, and Stampede on a second machine, with Channels users that behave like Phoenix's JavaScript client (a heartbeat every 30 seconds, a message every 30 to 60):

Users Join p95 Message p95 Errors Server CPU Server memory
100,000 5.71ms 0.57ms 0 1% 4.9 GB
200,000 6.84ms 1.35ms 0 3% 9.5 GB
300,000 9.42ms 2.67ms 0 9% 13.9 GB
400,000 12.46ms 4.07ms 0 24% 18.1 GB

At 500,000 the machine running Stampede ran out of CPU before Phoenix did, and the summary said so instead of blaming the server. Everything you need to run it yourself, and every number it measured, is in bench/million. The goal is still a million, once I have more load machines.

Each step ramps for 5 seconds and holds for 30 by default. --start, --step, --up-to and --hold change that, and find_limit: [by: :rate] in a scenario's options steps up the arrival rate instead of users. It's new in 1.2, so the options and the report may still change a little. --badge badge.svg writes a badge with the result, for your README:

load tested: 12,000+ VUs

Scenarios

A scenario is a module that implements Stampede.Scenario. Only run/1 is required:

When an iteration raises, the error is counted under iteration.errors and listed in the summary, and the VU simply starts its next iteration. Sockets opened during an iteration are closed when it ends, so nothing leaks from one iteration to the next.

VUs or a rate

There are two ways to apply load: keep a number of VUs busy, or start iterations at a fixed rate.

stages is a list of {duration, target} tuples, each moving the number of VUs in a straight line to its target. vus: 100, duration: [minute: 1] just holds 100 VUs for a minute. Durations are milliseconds, or anything Kernel.to_timeout/1 takes, like [second: 30].

The catch with VUs is that each one waits for its last iteration to finish before starting the next. When your server slows down, the load quietly eases off with it, and real users don't do that. rate keeps starting iterations on schedule, however long they take:

[rate: 200, duration: [minute: 5], max_vus: 1_000]
[rate_stages: [{[minute: 1], 500}, {[minute: 5], 500}]]

VUs are started as needed and reused, keeping their connections. max_vus caps how many iterations can be in flight, and the ones that can't start are counted as iterations.dropped.

On the command line, --vus 100 or --rate 200 with --duration 1m override whatever the scenario says.

VUs that aren't needed anymore, during a ramp down or at the end, are stopped right away. Set graceful_stop: [second: 30] to let them finish their current iteration first, so it gets recorded instead of cut off.

Mixing kinds of traffic

Real traffic is rarely one kind of user. Usually lots of people are reading while a few are writing. --parallel runs every scenario you give it at the same time, each with its own load and thresholds:

$ mix stampede.run loadtest/reading.exs loadtest/posting.exs --parallel

From code, it's Stampede.run_parallel([Reading, Posting]), which returns one result per scenario.

Checks and thresholds

Checks are assertions that don't stop the iteration when they fail:

{:ok, response} = Stampede.HTTP.get("http://localhost:4000/api/posts")
Stampede.check("status is 200", response.status == 200)

Thresholds are the limits a run has to stay within to pass:

thresholds: [
  {"channel.join", p95: 250, max: 2_000},
  {"status is 200", failed_rate: 0.01},
  {"iteration.errors", count: 0}
]

Timings have min, mean, p50, p90, p95, p99 and max, in milliseconds. Checks have failed and failed_rate, and every metric has count and rate, per second. Percentiles stay within 1% of the exact value, however many samples a run collects.

In your test suite

If you'd rather keep load tests with your other tests, Stampede.ExUnit fails a test when a threshold fails, with the run's summary in the failure message:

Code.require_file("../loadtest/lobby.exs", __DIR__)

defmodule MyApp.LoadTest do
  use ExUnit.Case
  import Stampede.ExUnit

  @moduletag :load

  test "the lobby holds 200 users" do
    assert_load(MyApp.Load.Lobby, vus: 200, duration: [second: 30])
  end
end

Put ExUnit.start(exclude: [:load]) in test/test_helper.exs, so they only run with mix test --only load.

Metrics

Every scenario records iteration, iteration.errors, iterations.dropped and vu.crashes, and each client below records its own. Add yours with Stampede.measure/2, Stampede.record/3, Stampede.increment/2 and Stampede.check/2. Timing a broadcast round trip, for example:

Stampede.measure("broadcast.roundtrip", fn ->
  :ok = Stampede.Channels.cast(socket, "room:lobby", "shout", %{from: vu.id})
  {:ok, _payload} = Stampede.Channels.await(socket, "room:lobby", "shout", match: &(&1["from"] == vu.id))
end)

HTTP

Stampede.HTTP records requests as http.request, and counts transport errors and responses of 400 or more under http.request.errors:

{:ok, %{status: 200, body: body}} = Stampede.HTTP.get("http://localhost:4000/api/posts")
{:ok, %{status: 201}} = Stampede.HTTP.post("http://localhost:4000/api/posts", json: %{title: "Hi"})

Each VU keeps its connections open between requests, like a browser, and reconnects when the server has closed one. On my laptop, against a Phoenix app on the same machine, 2,000 GET requests a second for 10 seconds held their rate with a p99 of 1.56ms.

Phoenix Channels

Stampede.Channels does what the JavaScript client does:

It records ws.connect, ws.disconnects, channel.join, channel.push, channel.leave and channel.messages, plus an .errors counter for each kind of request. A push to a channel that crashes fails right away with {:error, :channel_crashed}, instead of waiting for its timeout.

Phoenix Presence and Absinthe subscriptions both run over channels, and each has a guide.

LiveView

Stampede.LiveView loads the page, then joins the LiveView on it with the page's CSRF token and cookies, just like a browser:

@impl true
def run(_vu) do
  {:ok, view} = Stampede.LiveView.connect("http://localhost:4000/posts/new")
  {:ok, _reply} = Stampede.LiveView.change(view, "validate", %{"post" => %{"title" => "Hi"}})
  {:ok, _reply} = Stampede.LiveView.submit(view, "save", %{"post" => %{"title" => "Hi there"}})
  {:ok, _reply} = Stampede.LiveView.click(view, "like", %{"id" => "1"})
  {:ok, _view} = Stampede.LiveView.navigate(view, "/posts")
end

click/4, change/4 and submit/4 send what phx-click, phx-change and phx-submit would, and give back whatever handle_event/3 replied. Pass target: 1 to talk to a component, like phx-target does. await/2 waits for updates the LiveView pushes on its own.

patch/3 and navigate/3 work like <.link patch> and <.link navigate>, reusing the socket when LiveView allows it. If an event makes the LiveView navigate, you get {:error, {:live_redirect, %{"to" => to}}} back, the same as with Phoenix.LiveViewTest, and navigate/3 will follow it.

upload/4 sends files to a live_file_input, in chunks over the same socket, so you can submit the form with them afterwards:

photo = :crypto.strong_rand_bytes(200_000)
:ok = Stampede.LiveView.upload(view, "avatar", [%{name: "me.jpg", content: photo}])
{:ok, _reply} = Stampede.LiveView.submit(view, "save", %{"user" => %{"name" => "jo"}})

Pages behind a login work too. connect/2 follows redirects and keeps the cookies they set, so a route that signs in a test user and redirects is enough, and the login guide walks through it. LiveView runs record live.page, live.join, live.event, live.patch, live.navigate and live.upload.

Plain WebSockets

For servers that don't use Phoenix Channels, like a WebSock handler or a GraphQL subscription endpoint, there's Stampede.WebSocket:

{:ok, ws} = Stampede.WebSocket.connect("ws://localhost:4000/ws")
:ok = Stampede.WebSocket.send(ws, JSON.encode!(%{subscribe: "prices"}))
{:ok, {:text, text}} = Stampede.WebSocket.await(ws, match: &match?({:text, "{\"price\"" <> _}, &1))

It answers pings and holds on to the frames it receives until await/2 reads them, even after the server has closed the connection. It records ws.connect, ws.disconnects, ws.messages and ws.messages.sent.

Results

Stampede.run/2 returns a Stampede.Result with a summary of every metric and a timeline of each second of the run, which is handy for spotting when latency started to climb during a ramp. --out results.json saves all of it as JSON.

--html report.html writes the same charts as the dashboard into a single file with nothing else to load, so CI can keep it as an artifact.

To catch regressions, compare two runs:

$ mix stampede.run loadtest/api.exs --out main.json
$ git switch my-branch
$ mix stampede.run loadtest/api.exs --out branch.json
$ mix stampede.compare main.json branch.json --max-regression 10

It shows how the p50, p95 and p99 of every timing changed, and exits with status 1 if one got more than 10% slower.

There are also :telemetry events for every timing, counter and check, and for each second of a run, if you want the numbers in your own metrics system while a test runs. The Stampede docs list them.

Spreading the load

One machine will run out of CPU or ports long before a decent Phoenix server runs out of room. Start a node on each extra machine, from the same project, and hand them to the task:

$ iex --sname worker --cookie load -S mix
$ elixir --sname loader --cookie load -S mix stampede.run loadtest/lobby.exs --nodes worker@host

VUs and rates are split evenly, and the metrics merged as if it all ran on one machine. From code, pass nodes: [node() | Node.list()]. The guide covers the limits of a single machine and running across a network.

Without a Mix project

A load test can also be a plain script:

Mix.install([{:stampede, "~> 1.2"}])

defmodule Lobby do
  use Stampede.Scenario

  @impl true
  def run(_vu) do
    {:ok, socket} = Stampede.Channels.connect("ws://localhost:4000/socket")
    {:ok, _reply} = Stampede.Channels.join(socket, "room:lobby")
    Process.sleep(1_000)
  end
end

result = Stampede.run(Lobby, vus: 100, duration: [minute: 1], progress: true)
IO.puts(Stampede.Reporter.format(result))
System.halt(if result.passed?, do: 0, else: 1)

Guides

Why not k6, Locust or Artillery?

They're good tools, especially for plain HTTP APIs. For Phoenix, though, they see a channel as just another WebSocket, so you end up writing the protocol yourself: joins, refs, replies and heartbeats. LiveView is harder still, since the socket needs tokens from a page load before it can even connect. Stampede handles all of that, and because scenarios are Elixir, you can reuse your own modules and test data in them.

Status

Stampede is young. I've used it to load test a real-world Phoenix server (there's a case study with the numbers), and its test suite runs real load tests against a Phoenix app on every change. Still, expect rough edges, and please tell me when you hit one.

Roadmap

What I'm working on next:

If one of these would help you, or something else is missing, say so in Discussions.

Contributing

Bug reports go in the issues, questions and ideas in Discussions. If you'd like to work on Stampede itself, CONTRIBUTING.md explains how to get set up.

License

Copyright 2026 Mandar Joshi

Licensed under the Apache License, Version 2.0, found in the LICENSE file.