Building Real-Time Dashboards with LiveView
Telemetry integration, charting, and handling high-frequency updates in LiveView
Most real-time dashboard implementations are overcomplicated. Developers reach for WebSocket libraries, external state management, and JavaScript frameworks; LiveView already handles the hard parts. The BEAM's process model makes this kind of work almost trivially simple--if you understand the underlying patterns.
I've built several production dashboards handling thousands of concurrent users pushing metrics at sub-second intervals. The architecture that works isn't the one you'd expect from traditional web development. It's simpler.
The Architecture That Actually Works
Real-time dashboards have a fundamental tension: data arrives frequently, but humans can only perceive updates at roughly 10-20 frames per second.perceptual-threshold Pushing every metric update to the browser wastes bandwidth and CPU cycles. You need to decouple ingestion from presentation.
The pattern I keep coming back to is a buffered collector:
defmodule MyApp.Metrics.Collector do
use GenServer
@flush_interval 100 # milliseconds
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def record(metric_name, value) do
GenServer.cast(__MODULE__, {:record, metric_name, value, System.monotonic_time()})
end
def init(_opts) do
schedule_flush()
{:ok, %{buffer: %{}}}
end
def handle_cast({:record, name, value, timestamp}, state) do
buffer = Map.update(state.buffer, name, [{value, timestamp}], &[{value, timestamp} | &1])
{:noreply, %{state | buffer: buffer}}
end
def handle_info(:flush, state) do
aggregated = aggregate_buffer(state.buffer)
Phoenix.PubSub.broadcast(MyApp.PubSub, "metrics:updates", {:metrics, aggregated})
schedule_flush()
{:noreply, %{state | buffer: %{}}}
end
defp schedule_flush do
Process.send_after(self(), :flush, @flush_interval)
end
defp aggregate_buffer(buffer) do
Map.new(buffer, fn {name, values} ->
{name, %{
avg: Enum.sum(Enum.map(values, &elem(&1, 0))) / length(values),
min: Enum.min(Enum.map(values, &elem(&1, 0))),
max: Enum.max(Enum.map(values, &elem(&1, 0))),
count: length(values)
}}
end)
end
end
Two things worth noting in this design. The record/2 function uses GenServer.cast rather than call--fire-and-forget, no backpressure on the caller.genserver-cast And System.monotonic_time() instead of system_time because monotonic clocks never go backwards.monotonic-time
The collector buffers incoming metrics and flushes aggregated data every 100 milliseconds; this transforms potentially thousands of individual data points into a single PubSub broadcast. One flush. One message. Every connected LiveView picks it up.
The LiveView side is minimal:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "metrics:updates")
end
{:ok, assign(socket, metrics: %{}, chart_data: [])}
end
def handle_info({:metrics, aggregated}, socket) do
chart_data = update_chart_data(socket.assigns.chart_data, aggregated)
{:noreply, assign(socket, metrics: aggregated, chart_data: chart_data)}
end
defp update_chart_data(existing, new_metrics) do
timestamp = DateTime.utc_now()
point = Map.put(new_metrics, :timestamp, timestamp)
# Keep last 60 data points (6 seconds at 100ms intervals)
[point | existing] |> Enum.take(60)
end
end
The connected?(socket) guard matters more than it looks.connected-check This separation--collector, PubSub, LiveView--gives you three independent knobs to tune. Adjust the flush interval without touching the LiveView; add more collectors without changing the broadcast logic. Each piece has one job.
Telemetry Integration
Erlang's :telemetry library gives you a common instrumentation API that every serious Elixir library already speaks.telemetry-origin Phoenix emits events. Ecto emits events. Oban emits events. You just need to listen.
Attach handlers during application startup:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
attach_telemetry_handlers()
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
MyApp.Metrics.Collector,
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
defp attach_telemetry_handlers do
:telemetry.attach_many(
"myapp-metrics",
[
[:phoenix, :endpoint, :stop],
[:myapp, :repo, :query],
[:vm, :memory],
[:vm, :total_run_queue_lengths]
],
&MyApp.Metrics.TelemetryHandler.handle_event/4,
nil
)
end
end
The handler transforms raw telemetry into collector-friendly metrics:
defmodule MyApp.Metrics.TelemetryHandler do
alias MyApp.Metrics.Collector
def handle_event([:phoenix, :endpoint, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Collector.record("http.request.duration", duration_ms)
Collector.record("http.request.count", 1)
status_bucket = div(metadata.conn.status, 100) * 100
Collector.record("http.status.#{status_bucket}", 1)
end
def handle_event([:myapp, :repo, :query], measurements, _metadata, _config) do
duration_ms = System.convert_time_unit(measurements.total_time, :native, :millisecond)
Collector.record("db.query.duration", duration_ms)
end
def handle_event([:vm, :memory], measurements, _metadata, _config) do
Collector.record("vm.memory.total", measurements.total)
Collector.record("vm.memory.processes", measurements.processes)
Collector.record("vm.memory.binary", measurements.binary)
end
def handle_event([:vm, :total_run_queue_lengths], measurements, _metadata, _config) do
Collector.record("vm.run_queue.total", measurements.total)
Collector.record("vm.run_queue.cpu", measurements.cpu)
end
end
VM metrics don't arrive as events--you have to poll for them:
defmodule MyApp.Metrics.VMPoller do
use GenServer
@poll_interval 1000
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
schedule_poll()
{:ok, %{}}
end
def handle_info(:poll, state) do
:telemetry.execute([:vm, :memory], :erlang.memory())
:telemetry.execute([:vm, :total_run_queue_lengths], %{
total: :erlang.statistics(:total_run_queue_lengths),
cpu: :erlang.statistics(:total_run_queue_lengths_all)
})
schedule_poll()
{:noreply, state}
end
defp schedule_poll do
Process.send_after(self(), :poll, @poll_interval)
end
end
Charting: VegaLite vs Chart.js Hooks
Two solid options for rendering charts in LiveView; each involves a different set of tradeoffs.
VegaLite Approach
VegaLite integrates cleanly with LiveView through server-side specification:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
alias VegaLite, as: Vl
def render(assigns) do
~H"""
<div class="grid grid-cols-2 gap-4">
<div>
<h3>Request Latency</h3>
<%= raw(@latency_chart) %>
</div>
<div>
<h3>Memory Usage</h3>
<%= raw(@memory_chart) %>
</div>
</div>
"""
end
def handle_info({:metrics, aggregated}, socket) do
chart_data = update_chart_data(socket.assigns.chart_data, aggregated)
latency_chart = build_latency_chart(chart_data)
memory_chart = build_memory_chart(chart_data)
{:noreply, assign(socket,
metrics: aggregated,
chart_data: chart_data,
latency_chart: latency_chart,
memory_chart: memory_chart
)}
end
defp build_latency_chart(data) do
points = Enum.map(data, fn point ->
%{
"timestamp" => DateTime.to_iso8601(point.timestamp),
"latency" => get_in(point, ["http.request.duration", :avg]) || 0
}
end)
Vl.new(width: 400, height: 200)
|> Vl.data_from_values(points)
|> Vl.mark(:line)
|> Vl.encode_field(:x, "timestamp", type: :temporal, title: "Time")
|> Vl.encode_field(:y, "latency", type: :quantitative, title: "Latency (ms)")
|> Vl.to_spec()
|> VegaLite.Export.to_html()
end
end
The problem: you're re-rendering the entire chart specification on every update. For dashboards with many charts updating at 10Hz, this gets expensive fast.
Chart.js Hook Approach
A JavaScript hook gives you incremental updates--the chart object persists on the client, and you feed it new data points without tearing down and rebuilding:
// assets/js/hooks/chart.js
export const LineChart = {
mounted() {
const ctx = this.el.getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: this.el.dataset.label,
data: [],
borderColor: this.el.dataset.color || '#3b82f6',
tension: 0.1,
fill: false
}]
},
options: {
responsive: true,
animation: false, // Critical for performance
scales: {
x: { display: true },
y: { beginAtZero: true }
}
}
});
this.handleEvent("chart-update:" + this.el.id, ({points}) => {
this.chart.data.labels = points.map(p => p.label);
this.chart.data.datasets[0].data = points.map(p => p.value);
this.chart.update('none'); // 'none' skips animation
});
},
destroyed() {
this.chart.destroy();
}
};
That animation: false line isn't cosmetic.animation-false The LiveView side pushes events rather than re-rendering HTML:
def handle_info({:metrics, aggregated}, socket) do
chart_data = update_chart_data(socket.assigns.chart_data, aggregated)
points = Enum.map(chart_data, fn point ->
%{
label: Calendar.strftime(point.timestamp, "%H:%M:%S"),
value: get_in(point, ["http.request.duration", :avg]) || 0
}
end)
{:noreply,
socket
|> assign(metrics: aggregated, chart_data: chart_data)
|> push_event("chart-update:latency-chart", %{points: points})}
end
The template:
<canvas id="latency-chart"
phx-hook="LineChart"
data-label="Request Latency"
data-color="#3b82f6">
</canvas>
I prefer the hook approach for high-frequency dashboards. More setup, better runtime. The VegaLite path makes sense for dashboards that update every few seconds or where you need complex visualizations that would be painful to build in Chart.js--statistical plots, geographic projections, that kind of thing.
Handling High-Frequency Updates
When metrics arrive faster than humans can perceive, throttling at multiple layers becomes necessary.
The collector already batches at the ingestion layer. But you might also want LiveView-side throttling for especially busy systems:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
@throttle_ms 250
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "metrics:updates")
end
{:ok, assign(socket,
metrics: %{},
chart_data: [],
pending_update: nil,
last_push: 0
)}
end
def handle_info({:metrics, aggregated}, socket) do
now = System.monotonic_time(:millisecond)
time_since_last = now - socket.assigns.last_push
if time_since_last >= @throttle_ms do
{:noreply, apply_update(socket, aggregated, now)}
else
# Queue the update, schedule a delayed push
if is_nil(socket.assigns.pending_update) do
delay = @throttle_ms - time_since_last
Process.send_after(self(), :flush_pending, delay)
end
{:noreply, assign(socket, pending_update: aggregated)}
end
end
def handle_info(:flush_pending, socket) do
case socket.assigns.pending_update do
nil -> {:noreply, socket}
update ->
now = System.monotonic_time(:millisecond)
{:noreply, socket |> apply_update(update, now) |> assign(pending_update: nil)}
end
end
defp apply_update(socket, aggregated, now) do
chart_data = update_chart_data(socket.assigns.chart_data, aggregated)
socket
|> assign(metrics: aggregated, chart_data: chart_data, last_push: now)
|> push_chart_events(chart_data)
end
end
This caps the client at 4 updates per second regardless of how fast data arrives. The last update in any throttle window always gets delivered; you never show stale data, you just show it a fraction of a second later.
Efficient DOM Updates with Streams
Dashboards that display lists--log entries, recent events, active connections--need a different strategy than the assign-and-re-render cycle.streams-history Streams let LiveView track individual items without diffing the entire collection.
def mount(_params, _session, socket) do
{:ok, socket |> stream(:events, []) |> assign(event_count: 0)}
end
def handle_info({:new_event, event}, socket) do
{:noreply,
socket
|> stream_insert(:events, event, at: 0, limit: 100)
|> update(:event_count, &(&1 + 1))}
end
The template:
<div id="events" phx-update="stream">
<div :for={{dom_id, event} <- @streams.events} id={dom_id} class="event-row">
<span class="timestamp"><%= event.timestamp %></span>
<span class="message"><%= event.message %></span>
<span class={["level", event.level]}><%= event.level %></span>
</div>
</div>
That limit: 100 keeps memory bounded; old items drop from the DOM automatically when new ones push them out. For metrics that update in place rather than accumulate, temporary_assigns can reduce memory further:
def mount(_params, _session, socket) do
{:ok, assign(socket, metrics: %{}), temporary_assigns: [metrics: %{}]}
end
Putting It Together
A complete system metrics dashboard pulling all the pieces together--collector, telemetry, hooks, throttling:
defmodule MyAppWeb.SystemDashboardLive do
use MyAppWeb, :live_view
@refresh_interval 100
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "metrics:updates")
:timer.send_interval(@refresh_interval, :tick)
end
{:ok,
socket
|> assign(
metrics: initial_metrics(),
history: [],
connected_at: DateTime.utc_now()
)
|> stream(:events, [])}
end
def render(assigns) do
~H"""
<div class="dashboard">
<header class="dashboard-header">
<h1>System Dashboard</h1>
<span class="uptime">Connected: <%= format_duration(@connected_at) %></span>
</header>
<div class="metrics-grid">
<.metric_card
title="Memory"
value={format_bytes(@metrics.memory_total)}
subtitle={"Processes: #{format_bytes(@metrics.memory_processes)}"} />
<.metric_card
title="Run Queue"
value={@metrics.run_queue_total}
subtitle="Schedulers waiting" />
<.metric_card
title="Request Rate"
value={"#{@metrics.requests_per_sec}/s"}
subtitle={"Avg latency: #{@metrics.avg_latency}ms"} />
<.metric_card
title="Error Rate"
value={"#{Float.round(@metrics.error_rate * 100, 1)}%"}
subtitle="5xx responses" />
</div>
<div class="charts-row">
<div class="chart-container">
<h3>Request Latency</h3>
<canvas id="latency-chart" phx-hook="LineChart"
data-label="Latency (ms)" data-color="#3b82f6"></canvas>
</div>
<div class="chart-container">
<h3>Memory Usage</h3>
<canvas id="memory-chart" phx-hook="LineChart"
data-label="Memory (MB)" data-color="#10b981"></canvas>
</div>
</div>
<div class="events-panel">
<h3>Recent Events</h3>
<div id="events" phx-update="stream" class="events-list">
<div :for={{dom_id, event} <- @streams.events} id={dom_id} class="event-row">
<span class="timestamp"><%= format_time(event.timestamp) %></span>
<span class={["level", event.level]}><%= event.level %></span>
<span class="message"><%= event.message %></span>
</div>
</div>
</div>
</div>
"""
end
def handle_info({:metrics, raw}, socket) do
metrics = process_metrics(raw, socket.assigns.metrics)
history = update_history(socket.assigns.history, metrics)
{:noreply,
socket
|> assign(metrics: metrics, history: history)
|> push_chart_events(history)}
end
def handle_info({:event, event}, socket) do
{:noreply, stream_insert(socket, :events, event, at: 0, limit: 50)}
end
def handle_info(:tick, socket) do
# Heartbeat for uptime display
{:noreply, socket}
end
defp process_metrics(raw, previous) do
%{
memory_total: raw["vm.memory.total"][:avg] || previous.memory_total,
memory_processes: raw["vm.memory.processes"][:avg] || previous.memory_processes,
run_queue_total: raw["vm.run_queue.total"][:avg] || previous.run_queue_total,
requests_per_sec: (raw["http.request.count"][:count] || 0) * 10,
avg_latency: Float.round(raw["http.request.duration"][:avg] || 0, 1),
error_rate: calculate_error_rate(raw)
}
end
defp calculate_error_rate(raw) do
errors = raw["http.status.500"][:count] || 0
total = raw["http.request.count"][:count] || 1
errors / total
end
defp push_chart_events(socket, history) do
latency_points = Enum.map(history, fn h ->
%{label: h.label, value: h.avg_latency}
end)
memory_points = Enum.map(history, fn h ->
%{label: h.label, value: h.memory_total / 1_000_000}
end)
socket
|> push_event("chart-update:latency-chart", %{points: latency_points})
|> push_event("chart-update:memory-chart", %{points: memory_points})
end
defp update_history(history, metrics) do
point = Map.put(metrics, :label, Calendar.strftime(DateTime.utc_now(), "%H:%M:%S"))
[point | history] |> Enum.take(60)
end
defp initial_metrics do
%{
memory_total: 0,
memory_processes: 0,
run_queue_total: 0,
requests_per_sec: 0,
avg_latency: 0,
error_rate: 0
}
end
# Helper functions for formatting
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
defp format_time(datetime), do: Calendar.strftime(datetime, "%H:%M:%S")
defp format_duration(started_at) do
diff = DateTime.diff(DateTime.utc_now(), started_at, :second)
minutes = div(diff, 60)
seconds = rem(diff, 60)
"#{minutes}m #{seconds}s"
end
# Component for metric cards
attr :title, :string, required: true
attr :value, :string, required: true
attr :subtitle, :string, default: nil
defp metric_card(assigns) do
~H"""
<div class="metric-card">
<h4><%= @title %></h4>
<div class="value"><%= @value %></div>
<div :if={@subtitle} class="subtitle"><%= @subtitle %></div>
</div>
"""
end
end
What Goes Wrong in Production
A few things I've learned the hard way.
Don't subscribe to high-frequency topics directly from LiveView. Always have an aggregation layer between raw telemetry and your LiveView processes. Each connected client is a process; broadcasting 10,000 raw events per second to 1,000 clients means 10 million messages.pubsub-fanout The collector pattern above exists specifically to collapse that fan-out.
Use push_event for chart updates. Re-rendering SVG or canvas elements server-side on every tick is wasteful--let the client handle incremental updates. This is the one place where JavaScript hooks earn their keep.
Bound your history. It's easy to accidentally accumulate unbounded data in assigns; I've seen dashboards that worked perfectly for ten minutes then started crawling because someone forgot an Enum.take/2. Always cap your lists.
Test with realistic load. A dashboard that works fine at 10 requests per second will behave very differently at 10,000. Tools like k6 or wrk generate realistic traffic during development.tsung-alternative If your dashboard can't handle the load test, it definitely can't handle production.
Consider separate processes for heavy computation. Percentile calculations, histogram bucketing, moving averages--offload these to a dedicated GenServer rather than blocking the collector's flush cycle. The BEAM makes spinning up a new process for this kind of thing trivially cheap.
The mental model is clean: data flows from telemetry through PubSub to processes that render HTML. No JavaScript state management to wrangle. No WebSocket protocol to debug. No eventual consistency headaches. Processes sending messages to other processes; that's it.
The BEAM was built for this kind of work.beam-telephony
Fact-check notes:
- Telemetry event names for Phoenix and Ecto should be verified against current library versions
-
Chart.js API may have changed; verify the
update('none')syntax works with your version -
VegaLite export API should be verified against the current
vega_litehex package