Trading dashboards need sub-second price updates. Gaming platforms need live leaderboards. Collaboration tools need instant synchronization across thousands of users. All of these share a common challenge: real-time data at scale.
The traditional approach—building separate APIs, managing WebSocket connections, and synchronizing state between frontend and backend—is complex and error-prone. Here’s a simpler architecture that handles thousands of concurrent connections while keeping your codebase manageable.
Why LiveView?
Traditional real-time web apps require:
- A JavaScript framework (React, Vue, etc.)
- State synchronization between client and server
- API endpoints for every data mutation
- WebSocket connection management
LiveView eliminates this complexity. You write Elixir code, and LiveView automatically syncs state to the browser over WebSockets.
Setting Up a LiveView
Let’s build a real-time dashboard that updates stock prices:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| defmodule MyAppWeb.StockDashboardLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to price updates when WebSocket connects
Phoenix.PubSub.subscribe(MyApp.PubSub, "stock_prices")
# Update prices every second
:timer.send_interval(1000, self(), :update_prices)
end
stocks = [
%{symbol: "AAPL", price: 178.50, change: 0},
%{symbol: "GOOGL", price: 141.25, change: 0},
%{symbol: "MSFT", price: 378.90, change: 0}
]
{:ok, assign(socket, stocks: stocks, last_updated: DateTime.utc_now())}
end
@impl true
def render(assigns) do
~H"""
<div class="dashboard">
<h1>Stock Dashboard</h1>
<p class="updated">Last updated: <%= Calendar.strftime(@last_updated, "%H:%M:%S") %></p>
<div class="stocks-grid">
<%= for stock <- @stocks do %>
<div class={"stock-card #{change_class(stock.change)}"}>
<h2><%= stock.symbol %></h2>
<p class="price">$<%= :erlang.float_to_binary(stock.price, decimals: 2) %></p>
<p class="change">
<%= if stock.change >= 0, do: "+", else: "" %><%= :erlang.float_to_binary(stock.change, decimals: 2) %>%
</p>
</div>
<% end %>
</div>
<button phx-click="refresh" class="refresh-btn">
Refresh Now
</button>
</div>
"""
end
@impl true
def handle_info(:update_prices, socket) do
updated_stocks = Enum.map(socket.assigns.stocks, fn stock ->
# Simulate price changes
change = (:rand.uniform() - 0.5) * 2
new_price = stock.price * (1 + change / 100)
%{stock | price: new_price, change: change}
end)
{:noreply, assign(socket,
stocks: updated_stocks,
last_updated: DateTime.utc_now()
)}
end
@impl true
def handle_event("refresh", _params, socket) do
send(self(), :update_prices)
{:noreply, socket}
end
defp change_class(change) when change > 0, do: "positive"
defp change_class(change) when change < 0, do: "negative"
defp change_class(_), do: "neutral"
end
|
LiveView makes form handling elegant. Here’s a search component with live filtering:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
| defmodule MyAppWeb.UserSearchLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket,
query: "",
results: [],
loading: false
)}
end
@impl true
def render(assigns) do
~H"""
<div class="search-container">
<form phx-change="search" phx-submit="search">
<input
type="text"
name="query"
value={@query}
placeholder="Search users..."
phx-debounce="300"
autocomplete="off"
/>
</form>
<%= if @loading do %>
<div class="loading">Searching...</div>
<% end %>
<ul class="results">
<%= for user <- @results do %>
<li phx-click="select_user" phx-value-id={user.id}>
<strong><%= user.name %></strong>
<span><%= user.email %></span>
</li>
<% end %>
</ul>
<%= if @query != "" and Enum.empty?(@results) and not @loading do %>
<p class="no-results">No users found</p>
<% end %>
</div>
"""
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
if String.length(query) >= 2 do
# Start async search
send(self(), {:search, query})
{:noreply, assign(socket, query: query, loading: true)}
else
{:noreply, assign(socket, query: query, results: [], loading: false)}
end
end
@impl true
def handle_event("select_user", %{"id" => id}, socket) do
# Handle user selection
{:noreply, push_navigate(socket, to: ~p"/users/#{id}")}
end
@impl true
def handle_info({:search, query}, socket) do
results = MyApp.Users.search(query)
{:noreply, assign(socket, results: results, loading: false)}
end
end
|
Live Components for Reusability
Break complex UIs into reusable components:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| defmodule MyAppWeb.Components.NotificationBadge do
use MyAppWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, count: 0)}
end
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
@impl true
def render(assigns) do
~H"""
<div class="notification-badge" id={@id}>
<button phx-click="mark_read" phx-target={@myself}>
<span class="icon">🔔</span>
<%= if @count > 0 do %>
<span class="count"><%= @count %></span>
<% end %>
</button>
</div>
"""
end
@impl true
def handle_event("mark_read", _params, socket) do
# Mark notifications as read
{:noreply, assign(socket, count: 0)}
end
end
|
LiveView sends minimal diffs over the wire, but you can optimize further:
1. Use phx-update for Lists
1
2
3
4
5
| <ul id="messages" phx-update="append">
<%= for message <- @new_messages do %>
<li id={"message-#{message.id}"}><%= message.text %></li>
<% end %>
</ul>
|
2. Temporary Assigns for Large Lists
1
2
3
4
5
6
7
8
| def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:messages, [])
|> assign(:new_messages, load_initial_messages()),
temporary_assigns: [new_messages: []]
}
end
|
3. Use Streams for Efficient Collections
1
2
3
4
5
6
7
8
9
10
11
12
13
| def mount(_params, _session, socket) do
{:ok, stream(socket, :products, Products.list_products())}
end
def render(assigns) do
~H"""
<div id="products" phx-update="stream">
<div :for={{dom_id, product} <- @streams.products} id={dom_id}>
<%= product.name %>
</div>
</div>
"""
end
|
Conclusion
This architecture powers trading floors where traders need instant price updates, gaming platforms with live tournament leaderboards, and collaboration tools where teams work together in real-time. The advantages for these industries:
- Finance: Live portfolio updates, real-time risk dashboards, instant trade confirmations
- Gaming: Live leaderboards, in-game events, real-time multiplayer lobbies
- SaaS: Collaborative editing, live notifications, instant search and filtering
- IoT: Device status dashboards, sensor data visualization, alert systems
The same patterns scale from dozens to thousands of concurrent users without architectural changes.
At Sajima Solutions, we build real-time applications for finance, gaming, and SaaS platforms across the Gulf region. Contact us to discuss your live data challenges.