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

Handling User Input

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

Optimizing Performance

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.