The first public release of LiveLoad.
I'm trying to model this changelog around how I've seen the Phoenix and Elixir changelogs modeled. I quite like how they're structured, so I've structured mine in a similar way. Hopefully you enjoy reading it!
For the full development journey (and basically my personal development diary) from an empty repo to this release, see the Devlog.
If you are just getting started with LiveLoad, the Writing Your First Scenario guide is the best place to start. It covers everything from basic navigation to throttles, assigns, and the full scenario lifecycle.
Scenario API
LiveLoad scenarios are defined as modules implementing the LiveLoad.Scenario behaviour. The use LiveLoad.Scenario macro sets up the behaviour, imports all context functions, and provides default implementations for optional callbacks.
A scenario has three callbacks:
LiveLoad.Scenario.config/1: Called once per node. Receives any unconsumed options fromLiveLoad.run/1and returns a config value forwarded to everyLiveLoad.Scenario.run/3call.LiveLoad.Scenario.throttles/1: Called once per node. Returns a list of cluster-wide throttles for this scenario.LiveLoad.Scenario.run/3: The actual load test. Called in a loop per user until:scenario_durationexpires.
A minimal example scenario looks something like this:
defmodule MyApp.Scenarios.Dashboard do
use LiveLoad.Scenario
@impl true
def run(ctx, _user_id, _config) do
ctx
|> navigate("https://myapp.com/dashboard")
|> wait_for_liveview()
|> click("#refresh_data")
|> wait_for_phx_loading_completion(:click, "#refresh_data")
|> fill("#search", "test query")
|> submit_form("#search-form")
end
endContext pipeline
LiveLoad.Scenario.Context is modeled on Plug.Conn. It flows through a pipeline of browser operations, and if any step fails, the rest of the pipeline is skipped automatically. The error is captured on the context and reported by the runner.
The context supports assigns (persisted across iterations), resolvable values (1-arity functions receiving the context), and an :as option on extraction functions for assigning values from the page onto the context:
def run(ctx, user_id, config) do
ctx
|> navigate("#{config.base_url}/products")
|> wait_for_liveview()
|> get_attribute("#product", "data-id", as: :product_id)
|> click(&"#add-to-cart-#{&1.assigns.product_id}")
|> wait_for_phx_loading_completion(:click, &"#add-to-cart-#{&1.assigns.product_id}")
endLiveView-aware metrics
LiveLoad injects a JavaScript observer into every browser context via Playwright's addInitScript. This observer watches the DOM for LiveView's loading classes (phx-click-loading, phx-submit-loading, etc.) and emits timing telemetry when they are added and removed. This is how LiveLoad.Scenario.Context.wait_for_phx_loading_completion/3 measures the real time between "I clicked" and "the UI responded", from the browser's perspective.
HTTP request/response metrics and WebSocket frame sizes and rates are collected via PlaywrightEx's protocol subscription mechanism.
WebSocket vs Longpolling Transports
Phoenix.Socket-level metrics (frame sizes, frame rates) are collected cleanly over WebSocket connections.
If your app falls back to longpolling, those frame-level metrics won't be captured directly, since longpolling
is just HTTP from the browser's perspective. Browser-level LiveView metrics (mount times, phx-*-loading
durations) are recorded regardless of transport.
Distributed load testing
LiveLoad uses :amoc for distributed user simulation and FLAME for elastic node orchestration. When distributed?: true is set, LiveLoad.Cluster calculates the necessary node count based on available resources, primes the cluster eagerly via FLAME.call, and tears it all down when the test completes.
LiveLoad.run(
otp_app: :my_app,
users: 1_000,
distributed?: true,
flame_backend: FLAME.FlyBackend,
cluster_opts: [
flame_backend_opts: [app: :my_runner, cpus: 8, memory_mb: 16 * 1024],
max_allowed_nodes: 100
]
)Throttles
Three throttle types are available, all cluster-wide via :amoc_throttle:
LiveLoad.Scenario.Throttle.Rate: A basic rate limiter which limits to a specific number of events per interval configured.LiveLoad.Scenario.Throttle.Interarrival: A rate limiter which defines the amount of time between each event.LiveLoad.Scenario.Throttle.Parallelism: A rate limiter which ensures a specific number of concurrent executions.
Rate and Interarrival support gradual ramp-ups via :duration or :steps + :interval:
def throttles(_config) do
[
:visitors |> Rate.new(10) |> Rate.ramp(100, duration: to_timeout(minute: 5))
]
endResults and reporters
LiveLoad.Result is a compact, JSON-serializable struct containing 101-point precomputed quantile curves, dimensioned histograms and counters, time-series buckets, and per-node breakdowns. Reporters don't need to know anything about LiveLoad's internals, the entire serialized result is precomputed.
Two reporters ship with this release:
LiveLoad.Reporter.HTML: a self-contained single-file HTML report with an embedded React SPA. Run data is gzipped and base64 encoded into the page.LiveLoad.Reporter.Markdown: a simple tabular output for quick inspection.
results = LiveLoad.run(otp_app: :my_app, users: 50)
html = LiveLoad.Reporter.HTML.render!(results)
File.write!("liveload_report.html", html)v0.1.0 (2026-04-23)
Enhancements
Core
- [LiveLoad] Add
LiveLoad.run/1as the main entrypoint for running load tests - [LiveLoad.Scenario] Add behaviour for defining load test scenarios with
config/1,throttles/1, andrun/3callbacks - [LiveLoad.Scenario] Add
use LiveLoad.Scenariomacro that sets up the behaviour, imports context functions, and provides default callback implementations - [LiveLoad.Scenario.Runner] Add
gen_statembased scenario runner that loopsrun/3until duration expires, halt, or failure - [LiveLoad.Scenario.Discovery] Add various levels of automatic scenario discovery to allow running scenarios in different ways
Scenario Context
- [LiveLoad.Scenario.Context] Add
Plug.Conninspired pipeline struct with automatic error short-circuiting - [LiveLoad.Scenario.Context] Add navigation functions:
navigate/2,reload/1 - [LiveLoad.Scenario.Context] Add element interaction functions:
click/2,fill/3,clear/2,press/3,check/2,uncheck/2,select_option/3,select_multiple_options/3,focus/2,blur/2,hover/2,drag_and_drop/3,wait_for_selector/2 - [LiveLoad.Scenario.Context] Add LiveView specific functions:
ensure_liveview/1,wait_for_liveview/1,wait_for_phx_loading_completion/3,submit_form/2 - [LiveLoad.Scenario.Context] Add value extraction functions with
:asoption:page_content/2,inner_html/3,inner_text/3,text_content/3,input_value/3,get_attribute/4,visible?/3,checked?/3 - [LiveLoad.Scenario.Context] Add assigns management:
assign/3,reset_assigns/1,clear_assign/2,update_assign!/3 - [LiveLoad.Scenario.Context] Add flow control:
halt/1,fail/2,halted?/1,failed?/1 - [LiveLoad.Scenario.Context] Add browser storage functions:
context_storage_snapshot/2,restore_context_storage/2,reset_context_storage/1 - [LiveLoad.Scenario.Context] Support resolvable values (1-arity functions receiving the context) for selectors, URLs, and values
Throttles
- [LiveLoad.Scenario.Throttle.Rate] Add rate limiter with configurable interval and gradual ramp-up support
- [LiveLoad.Scenario.Throttle.Interarrival] Add interarrival-based throttle with gradual ramp-up support
- [LiveLoad.Scenario.Throttle.Parallelism] Add concurrency limiter
Browser
- [LiveLoad.Browser] Add browser abstraction with pluggable
LiveLoad.Browser.Connectionbehaviour - [LiveLoad.Browser.Connection.Playwright] Add Playwright based browser connection using PlaywrightEx
- [Mix.Tasks.LiveLoad.Install] Add
mix live_load.installtask to download the Playwright standalone driver and Chromium binaries
Distribution
- [LiveLoad.Cluster] Add FLAME based elastic cluster formation with automatic node count calculation based on resource availability
- [LiveLoad.Cluster.Node] Add
FLAME.Trackablenode struct for eager cluster priming - [LiveLoad.Cluster.AmocSeed] Add parallel cluster seeding with direct pinging to work around gossip deadlocks on larger clusters
Telemetry and Results
- [LiveLoad.Telemetry.Listener] Add per node telemetry listener collecting metrics into DDSketch structures via
ddskerl - [LiveLoad.Telemetry.Collector] Add primary node collector that merges sketches from all nodes into a single result
- [LiveLoad.Result] Add compact, JSON-serializable result struct with 101-point precomputed quantile curves, dimensioned histograms and counters, time-series buckets, and per-node breakdowns
Reporters
- [LiveLoad.Reporter.Markdown] Add simple tabular markdown reporter
- [LiveLoad.Reporter.HTML] Add self-contained single-file HTML reporter with embedded React/TypeScript SPA