Building a Scalable Chat Application with Elixir, Phoenix, and DynamoDB: A Complete Guide
Building a real-time chat application that can scale to millions of users requires careful architectural decisions. You need lightning-fast message delivery, reliable persistence, and the ability to scale horizontally without massive infrastructure costs.
In this comprehensive guide, weβll build a production-ready chat application that combines Elixirβs legendary concurrency capabilities with AWS DynamoDBβs serverless scalability. This stack powers some of the worldβs largest real-time applications and can handle virtually unlimited scale with pay-per-use pricing.
Why This Stack?
Elixir + Phoenix: Built for Real-Time at Scale
Elixir, running on the battle-tested BEAM VM (Erlang Virtual Machine), was specifically designed for distributed, fault-tolerant, real-time systems:
- Millions of concurrent connections on a single server (Phoenix has demonstrated 2M+ concurrent WebSocket connections)
- Sub-millisecond message latency via lightweight processes
- Phoenix Channels for WebSocket communication with automatic reconnection
- Built-in clustering for horizontal scaling across multiple servers
- Fault tolerance with supervision trees and βlet it crashβ philosophy
- Low-latency garbage collection that doesnβt pause your application
Real-world proof:
- Discord handles 5+ million concurrent users with Elixir
- WhatsApp (built on Erlang) supported 900 million users with just 50 engineers
- Bleacher Report handles 8+ million concurrent sports fans during major events
AWS DynamoDB: Serverless NoSQL at Global Scale
DynamoDB is Amazonβs fully managed NoSQL database designed for applications requiring consistent, single-digit millisecond latency at any scale:
- Serverless architecture - no servers to manage or provision
- Unlimited scalability - automatically scales up and down based on traffic
- Global tables - multi-region, active-active replication for low latency worldwide
- Pay-per-use pricing - only pay for what you use
- Built-in backup and point-in-time recovery
- Event streaming with DynamoDB Streams for real-time data processing
- Consistent performance at any scale (single-digit millisecond reads/writes)
Why This Combination Excels
Elixir handles the real-time: WebSocket connections, message broadcasting, presence tracking, and in-memory state management.
DynamoDB handles persistence: Message history, user data, room metadata with global availability and unlimited scalability.
This separation of concerns allows each technology to do what it does best, resulting in a system that can:
- Handle millions of concurrent chat sessions
- Store billions of messages without performance degradation
- Scale globally with low latency
- Maintain 99.99% uptime
- Cost-optimize with pay-per-use pricing
Architecture Overview
Our chat application architecture leverages both Elixirβs concurrency and DynamoDBβs scalability:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Load Balancer (ALB/ELB) β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββ΄ββββββββββββββββββ
β β
βββββββββΌβββββββββ βββββββββΌβββββββββ
β Phoenix Node 1 β β Phoenix Node 2 β
β ββββββββββββββββ΄β β ββββββββββββββββ΄β
β β Phoenix Node 3ββββClusteringβββΊβ β Phoenix Node N β
β ββββββββββββββββ¬β β ββββββββββββββββ¬β
β β β β
β βββββββββββββββΌβββββββββββββββ β β
β β Phoenix Channels β β β
β β - WebSocket handling β β β
β β - Message broadcasting β β β
β β - Presence tracking β β β
β β - Rate limiting β β β
β βββββββββββββββ¬βββββββββββββββ β β
β β β β
β βββββββββββββββΌβββββββββββββββ β β
β β Phoenix LiveView β β β
β β - Chat UI β β β
β β - Real-time updates β β β
β ββββββββββββββββββββββββββββββ β β
ββββββββββββββββββ¬βββββββββββββββββββ β
β β
βββββββββββββββ¬ββββββββββββββββββββββ
β
ββββββββββββββββΌββββββββββββββββ
β AWS DynamoDB β
β ββββββββββββββββββββββββββ β
β β Users Table β β
β β - userId (PK) β β
β β - username, email β β
β β - created_at β β
β ββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββ β
β β Rooms Table β β
β β - roomId (PK) β β
β β - name, description β β
β β - created_at β β
β ββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββ β
β β Messages Table β β
β β - roomId (PK) β β
β β - timestamp (SK) β β
β β - messageId, userId β β
β β - content β β
β β - GSI: userId-timestampβ β
β ββββββββββββββββββββββββββ β
β β
β Global Tables (Optional) β
β - Multi-region replication β
β - Active-active writes β
ββββββββββββββββββββββββββββββββ
Key Design Decisions:
- Phoenix Channels for Real-Time: Handles all WebSocket connections and message broadcasting
- DynamoDB for Persistence: Stores users, rooms, and message history with infinite scalability
- Phoenix Clustering: Multiple Phoenix nodes share load and provide redundancy
- Global Tables (Optional): DynamoDB replication across AWS regions for global low latency
- Streams for Events: DynamoDB Streams can trigger Lambda functions for notifications, analytics, etc.
Prerequisites
Before we start, ensure you have:
# Elixir 1.15+ and Erlang/OTP 26+
elixir --version
# Phoenix 1.7+
mix archive.install hex phx_new
# AWS CLI configured with credentials
aws --version
aws configure
# Verify AWS credentials
aws sts get-caller-identity
Youβll also need an AWS account with permissions to create DynamoDB tables.
Step 1: Create the Phoenix Application
Letβs start by generating a new Phoenix application optimized for real-time features:
# Generate Phoenix app with LiveView
mix phx.new chat_app --live
cd chat_app
# Install dependencies
mix deps.get
# We won't use Ecto since we're using DynamoDB
# Remove Ecto from the application if you don't need it for other data
Add DynamoDB Dependencies
Update your mix.exs to include AWS SDK libraries:
# mix.exs
defp deps do
[
{:phoenix, "~> 1.7.10"},
{:phoenix_html, "~> 3.3"},
{:phoenix_live_reload, "~> 1.4", only: :dev},
{:phoenix_live_view, "~> 0.20.1"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.2"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
{:swoosh, "~> 1.3"},
{:finch, "~> 0.13"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.20"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
# DynamoDB dependencies
{:ex_aws, "~> 2.5"},
{:ex_aws_dynamo, "~> 4.2"},
{:hackney, "~> 1.18"},
{:sweet_xml, "~> 0.7"},
{:configparser_ex, "~> 4.0"},
# For generating UUIDs
{:uuid, "~> 1.1"}
]
end
Run mix deps.get to install the new dependencies.
# config/config.exs
config :ex_aws,
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
region: {:system, "AWS_REGION"}
# For development, you can use local credentials
# config/dev.exs
import Config
config :ex_aws,
access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"),
region: System.get_env("AWS_REGION") || "us-east-1"
Step 2: Create DynamoDB Tables
DynamoDB table design is crucial for performance. Unlike SQL databases, you need to design your tables based on access patterns.
DynamoDB Table Design
Access Patterns:
- Get user by userId
- Get room by roomId
- Get all messages in a room (paginated, newest first)
- Get all messages by a user
- Get recent messages in a room
Tables:
-
Users Table
- Partition Key:
userId (String)
- Attributes:
username, email, passwordHash, createdAt
-
Rooms Table
- Partition Key:
roomId (String)
- Attributes:
name, description, slug, createdAt
- GSI:
slug-index (for lookup by slug)
-
Messages Table
- Partition Key:
roomId (String)
- Sort Key:
timestamp (Number - Unix timestamp in milliseconds)
- Attributes:
messageId, userId, username, content, createdAt
- GSI:
userId-timestamp-index (for userβs message history)
Create Tables with AWS CLI
# Create Users table
aws dynamodb create-table \
--table-name ChatApp_Users \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
--key-schema \
AttributeName=userId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--tags Key=Application,Value=ChatApp
# Create Rooms table with GSI for slug lookup
aws dynamodb create-table \
--table-name ChatApp_Rooms \
--attribute-definitions \
AttributeName=roomId,AttributeType=S \
AttributeName=slug,AttributeType=S \
--key-schema \
AttributeName=roomId,KeyType=HASH \
--global-secondary-indexes \
'[{
"IndexName": "slug-index",
"KeySchema": [{"AttributeName":"slug","KeyType":"HASH"}],
"Projection": {"ProjectionType":"ALL"}
}]' \
--billing-mode PAY_PER_REQUEST \
--tags Key=Application,Value=ChatApp
# Create Messages table with GSI for user message lookup
aws dynamodb create-table \
--table-name ChatApp_Messages \
--attribute-definitions \
AttributeName=roomId,AttributeType=S \
AttributeName=timestamp,AttributeType=N \
AttributeName=userId,AttributeType=S \
--key-schema \
AttributeName=roomId,KeyType=HASH \
AttributeName=timestamp,KeyType=RANGE \
--global-secondary-indexes \
'[{
"IndexName": "userId-timestamp-index",
"KeySchema": [
{"AttributeName":"userId","KeyType":"HASH"},
{"AttributeName":"timestamp","KeyType":"RANGE"}
],
"Projection": {"ProjectionType":"ALL"}
}]' \
--billing-mode PAY_PER_REQUEST \
--tags Key=Application,Value=ChatApp
# Wait for tables to be created
aws dynamodb wait table-exists --table-name ChatApp_Users
aws dynamodb wait table-exists --table-name ChatApp_Rooms
aws dynamodb wait table-exists --table-name ChatApp_Messages
echo "All tables created successfully!"
Seed Some Initial Data
# Create a default room
aws dynamodb put-item \
--table-name ChatApp_Rooms \
--item '{
"roomId": {"S": "general"},
"name": {"S": "General"},
"description": {"S": "General discussion room"},
"slug": {"S": "general"},
"createdAt": {"S": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'"}
}'
aws dynamodb put-item \
--table-name ChatApp_Rooms \
--item '{
"roomId": {"S": "tech-talk"},
"name": {"S": "Tech Talk"},
"description": {"S": "Discuss technology and programming"},
"slug": {"S": "tech-talk"},
"createdAt": {"S": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'"}
}'
Step 3: Create DynamoDB Access Layer
Create a module to interact with DynamoDB:
# lib/chat_app/dynamo.ex
defmodule ChatApp.Dynamo do
@moduledoc """
DynamoDB client wrapper for the chat application.
"""
alias ExAws.Dynamo
@users_table "ChatApp_Users"
@rooms_table "ChatApp_Rooms"
@messages_table "ChatApp_Messages"
# ============================================================================
# Users
# ============================================================================
def get_user(user_id) do
case Dynamo.get_item(@users_table, %{userId: user_id}) |> ExAws.request() do
{:ok, %{"Item" => item}} -> {:ok, decode_user(item)}
{:ok, %{}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
def create_user(attrs) do
user_id = UUID.uuid4()
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
item = %{
userId: user_id,
username: attrs.username,
email: attrs.email,
passwordHash: attrs.password_hash,
createdAt: timestamp
}
case Dynamo.put_item(@users_table, item) |> ExAws.request() do
{:ok, _} -> {:ok, item}
{:error, reason} -> {:error, reason}
end
end
defp decode_user(item) do
%{
user_id: item["userId"]["S"],
username: item["username"]["S"],
email: item["email"]["S"],
password_hash: item["passwordHash"]["S"],
created_at: item["createdAt"]["S"]
}
end
# ============================================================================
# Rooms
# ============================================================================
def get_room(room_id) do
case Dynamo.get_item(@rooms_table, %{roomId: room_id}) |> ExAws.request() do
{:ok, %{"Item" => item}} -> {:ok, decode_room(item)}
{:ok, %{}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
def get_room_by_slug(slug) do
case Dynamo.query(@rooms_table,
index_name: "slug-index",
expression_attribute_values: [slug: slug],
key_condition_expression: "slug = :slug"
)
|> ExAws.request() do
{:ok, %{"Items" => [item | _]}} -> {:ok, decode_room(item)}
{:ok, %{"Items" => []}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
def list_rooms do
case Dynamo.scan(@rooms_table) |> ExAws.request() do
{:ok, %{"Items" => items}} -> {:ok, Enum.map(items, &decode_room/1)}
{:error, reason} -> {:error, reason}
end
end
def create_room(attrs) do
room_id = attrs[:room_id] || UUID.uuid4()
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
item = %{
roomId: room_id,
name: attrs.name,
description: attrs[:description] || "",
slug: attrs.slug,
createdAt: timestamp
}
case Dynamo.put_item(@rooms_table, item) |> ExAws.request() do
{:ok, _} -> {:ok, item}
{:error, reason} -> {:error, reason}
end
end
defp decode_room(item) do
%{
room_id: item["roomId"]["S"],
name: item["name"]["S"],
description: item["description"]["S"],
slug: item["slug"]["S"],
created_at: item["createdAt"]["S"]
}
end
# ============================================================================
# Messages
# ============================================================================
def create_message(attrs) do
message_id = UUID.uuid4()
timestamp = System.system_time(:millisecond)
created_at = DateTime.utc_now() |> DateTime.to_iso8601()
item = %{
roomId: attrs.room_id,
timestamp: timestamp,
messageId: message_id,
userId: attrs.user_id,
username: attrs.username,
content: attrs.content,
createdAt: created_at
}
case Dynamo.put_item(@messages_table, item) |> ExAws.request() do
{:ok, _} ->
{:ok,
%{
message_id: message_id,
room_id: attrs.room_id,
timestamp: timestamp,
user_id: attrs.user_id,
username: attrs.username,
content: attrs.content,
created_at: created_at
}}
{:error, reason} ->
{:error, reason}
end
end
def list_messages(room_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
# Query messages in reverse chronological order
query_opts = [
expression_attribute_values: [room_id: room_id],
key_condition_expression: "roomId = :room_id",
scan_index_forward: false, # Sort descending (newest first)
limit: limit
]
# Add pagination support if last_evaluated_key is provided
query_opts =
case Keyword.get(opts, :exclusive_start_key) do
nil -> query_opts
key -> Keyword.put(query_opts, :exclusive_start_key, key)
end
case Dynamo.query(@messages_table, query_opts) |> ExAws.request() do
{:ok, %{"Items" => items} = response} ->
messages = Enum.map(items, &decode_message/1)
last_key = Map.get(response, "LastEvaluatedKey")
{:ok, %{messages: messages, last_evaluated_key: last_key}}
{:error, reason} ->
{:error, reason}
end
end
def list_recent_messages(room_id, limit \\ 50) do
case list_messages(room_id, limit: limit) do
{:ok, %{messages: messages}} ->
# Reverse to show oldest first in chat UI
{:ok, Enum.reverse(messages)}
{:error, reason} ->
{:error, reason}
end
end
defp decode_message(item) do
%{
message_id: item["messageId"]["S"],
room_id: item["roomId"]["S"],
timestamp: String.to_integer(item["timestamp"]["N"]),
user_id: item["userId"]["S"],
username: item["username"]["S"],
content: item["content"]["S"],
created_at: item["createdAt"]["S"]
}
end
end
Step 4: Implement Phoenix Channels for Real-Time Chat
Phoenix Channels provide the WebSocket infrastructure for real-time messaging:
Create the Room Channel
# lib/chat_app_web/channels/room_channel.ex
defmodule ChatAppWeb.RoomChannel do
use ChatAppWeb, :channel
alias ChatApp.Dynamo
alias ChatAppWeb.Presence
require Logger
@impl true
def join("room:" <> room_slug, _params, socket) do
# Verify user is authenticated
case socket.assigns[:user_id] do
nil ->
{:error, %{reason: "unauthorized"}}
user_id ->
# Load room from DynamoDB
case Dynamo.get_room_by_slug(room_slug) do
{:error, :not_found} ->
{:error, %{reason: "room not found"}}
{:ok, room} ->
# Send presence tracking after join
send(self(), :after_join)
# Load recent messages from DynamoDB
{:ok, messages} = Dynamo.list_recent_messages(room.room_id, 50)
Logger.info("User #{user_id} joined room #{room_slug}")
{:ok,
%{
room: %{
id: room.room_id,
name: room.name,
description: room.description,
slug: room.slug
},
messages: format_messages(messages)
}, assign(socket, :room, room)}
{:error, reason} ->
Logger.error("Failed to load room: #{inspect(reason)}")
{:error, %{reason: "failed to load room"}}
end
end
end
@impl true
def handle_in("new_message", %{"content" => content}, socket) do
user_id = socket.assigns.user_id
username = socket.assigns.username
room = socket.assigns.room
# Validate message
content = String.trim(content)
if String.length(content) == 0 do
{:reply, {:error, %{reason: "Message cannot be empty"}}, socket}
else
# Create and persist message to DynamoDB
case Dynamo.create_message(%{
content: content,
user_id: user_id,
username: username,
room_id: room.room_id
}) do
{:ok, message} ->
# Broadcast to all users in the room
broadcast!(socket, "new_message", %{
id: message.message_id,
content: message.content,
user: %{
id: message.user_id,
username: message.username
},
timestamp: message.timestamp,
created_at: message.created_at
})
{:reply, {:ok, %{message_id: message.message_id}}, socket}
{:error, reason} ->
Logger.error("Failed to create message: #{inspect(reason)}")
{:reply, {:error, %{reason: "failed to send message"}}, socket}
end
end
end
@impl true
def handle_in("typing", _params, socket) do
username = socket.assigns.username
# Broadcast typing indicator to others (not self)
broadcast_from!(socket, "user_typing", %{
username: username
})
{:noreply, socket}
end
@impl true
def handle_in("load_more_messages", %{"last_timestamp" => last_timestamp}, socket) do
room = socket.assigns.room
# In production, you'd implement proper pagination with LastEvaluatedKey
# For now, we'll just return empty
{:reply, {:ok, %{messages: [], has_more: false}}, socket}
end
@impl true
def handle_info(:after_join, socket) do
user_id = socket.assigns.user_id
username = socket.assigns.username
room = socket.assigns.room
# Track user presence in this room
{:ok, _} =
Presence.track(socket, user_id, %{
online_at: System.system_time(:second),
user_id: user_id,
username: username
})
# Push current presence list to user
push(socket, "presence_state", Presence.list(socket))
# Broadcast join event
broadcast_from!(socket, "user_joined", %{
username: username
})
Logger.info("User #{username} presence tracked in room #{room.slug}")
{:noreply, socket}
end
# Handle user leaving
@impl true
def terminate(_reason, socket) do
username = socket.assigns[:username]
room = socket.assigns[:room]
if username && room do
broadcast_from!(socket, "user_left", %{
username: username
})
end
:ok
end
# Helper function to format messages
defp format_messages(messages) do
Enum.map(messages, fn msg ->
%{
id: msg.message_id,
content: msg.content,
user: %{
id: msg.user_id,
username: msg.username
},
timestamp: msg.timestamp,
created_at: msg.created_at
}
end)
end
end
Set Up Presence Tracking
# lib/chat_app_web/channels/presence.ex
defmodule ChatAppWeb.Presence do
@moduledoc """
Provides presence tracking to channels and processes.
See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
docs for more details.
"""
use Phoenix.Presence,
otp_app: :chat_app,
pubsub_server: ChatApp.PubSub
end
Add Presence to your application supervision tree:
# lib/chat_app/application.ex
defmodule ChatApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Start the Telemetry supervisor
ChatAppWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: ChatApp.PubSub},
# Start the Presence system
ChatAppWeb.Presence,
# Start the Endpoint (http/https)
ChatAppWeb.Endpoint
# Start a worker by calling: ChatApp.Worker.start_link(arg)
# {ChatApp.Worker, arg}
]
opts = [strategy: :one_for_one, name: ChatApp.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ChatAppWeb.Endpoint.config_change(changed, removed)
:ok
end
end
# lib/chat_app_web/channels/user_socket.ex
defmodule ChatAppWeb.UserSocket do
use Phoenix.Socket
# Channels
channel "room:*", ChatAppWeb.RoomChannel
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
# In production, verify JWT token or session token
# For demo, we'll just extract user info from token
case verify_token(token) do
{:ok, user_id, username} ->
{:ok, assign(socket, user_id: user_id, username: username)}
{:error, _reason} ->
:error
end
end
def connect(_params, _socket, _connect_info), do: :error
@impl true
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
# Simple token verification (use proper JWT in production)
defp verify_token(token) do
case String.split(token, ":") do
[user_id, username] -> {:ok, user_id, username}
_ -> {:error, :invalid_token}
end
end
end
Update lib/chat_app_web/endpoint.ex to include the socket:
# lib/chat_app_web/endpoint.ex
defmodule ChatAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :chat_app
# Socket configuration
socket "/socket", ChatAppWeb.UserSocket,
websocket: [
timeout: 45_000,
transport_log: false
],
longpoll: false
# ... rest of endpoint configuration
end
Step 5: Build the Chat UI with LiveView
Phoenix LiveView provides a reactive UI without complex JavaScript:
# lib/chat_app_web/live/chat_live.ex
defmodule ChatAppWeb.ChatLive do
use ChatAppWeb, :live_view
alias ChatApp.Dynamo
@impl true
def mount(%{"slug" => room_slug}, session, socket) do
# Get user from session (in production, use proper authentication)
user_id = session["user_id"] || "user-#{:rand.uniform(1000)}"
username = session["username"] || "User#{:rand.uniform(1000)}"
if connected?(socket) do
case Dynamo.get_room_by_slug(room_slug) do
{:ok, room} ->
# Load recent messages
{:ok, messages} = Dynamo.list_recent_messages(room.room_id, 50)
{:ok,
assign(socket,
room: room,
messages: messages,
user_id: user_id,
username: username,
new_message: "",
token: "#{user_id}:#{username}"
)}
{:error, :not_found} ->
{:ok,
socket
|> put_flash(:error, "Room not found")
|> redirect(to: "/")}
end
else
# For initial render
{:ok,
assign(socket,
room: nil,
messages: [],
user_id: user_id,
username: username,
new_message: "",
token: "#{user_id}:#{username}"
)}
end
end
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col h-screen max-w-6xl mx-auto bg-gray-50">
<!-- Room Header -->
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-6 shadow-lg">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold"><%= if @room, do: @room.name, else: "Loading..." %></h1>
<p class="text-blue-100 mt-1"><%= if @room, do: @room.description, else: "" %></p>
</div>
<div class="text-right">
<div class="text-sm text-blue-100">Logged in as</div>
<div class="font-semibold"><%= @username %></div>
</div>
</div>
</div>
<!-- Messages Container -->
<div
id="messages-container"
class="flex-1 overflow-y-auto p-6 space-y-4"
phx-update="append"
>
<%= for message <- @messages do %>
<div id={"message-#{message.message_id}"} class="group">
<div class="flex items-start space-x-3">
<!-- Avatar -->
<div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
<%= String.first(message.username) %>
</div>
</div>
<!-- Message Content -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline space-x-2">
<span class="font-semibold text-gray-900">
<%= message.username %>
</span>
<span class="text-xs text-gray-500">
<%= format_timestamp(message.created_at) %>
</span>
</div>
<div class="mt-1 text-gray-800 break-words">
<%= message.content %>
</div>
</div>
</div>
</div>
<% end %>
</div>
<!-- Message Input -->
<div class="border-t bg-white p-4 shadow-lg">
<.simple_form for={%{}} phx-submit="send_message" class="flex gap-3">
<input
type="text"
name="message"
value={@new_message}
placeholder="Type your message..."
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autocomplete="off"
phx-hook="ChatInput"
/>
<button
type="submit"
class="px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Send
</button>
</.simple_form>
</div>
<!-- WebSocket Connection Script -->
<script>
window.userToken = "<%= @token %>";
window.roomSlug = "<%= if @room, do: @room.slug, else: "" %>";
</script>
</div>
"""
end
@impl true
def handle_event("send_message", %{"message" => content}, socket) do
content = String.trim(content)
if content != "" do
# Create message in DynamoDB
case Dynamo.create_message(%{
content: content,
user_id: socket.assigns.user_id,
username: socket.assigns.username,
room_id: socket.assigns.room.room_id
}) do
{:ok, message} ->
# Append to messages list
{:noreply,
socket
|> update(:messages, fn messages -> messages ++ [message] end)
|> assign(:new_message, "")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to send message")}
end
else
{:noreply, socket}
end
end
# Helper functions
defp format_timestamp(iso_timestamp) do
case DateTime.from_iso8601(iso_timestamp) do
{:ok, datetime, _offset} ->
Calendar.strftime(datetime, "%I:%M %p")
{:error, _} ->
iso_timestamp
end
end
end
Add Client-Side WebSocket Connection
// assets/js/app.js
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
// Setup Phoenix Socket for Channels
let socket = new Socket("/socket", {
params: {token: window.userToken}
})
// Connect to the socket
socket.connect()
// Setup LiveView
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let Hooks = {}
// Auto-scroll to bottom when new messages arrive
Hooks.ChatInput = {
mounted() {
this.el.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
this.el.form.dispatchEvent(new Event("submit", {bubbles: true}))
}
})
}
}
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
liveSocket.connect()
// Optionally connect to a room channel for real-time updates
if (window.roomSlug && window.userToken) {
let channel = socket.channel(`room:${window.roomSlug}`, {})
channel.on("new_message", payload => {
console.log("New message received:", payload)
// LiveView will handle updates, but you could add sound notifications here
})
channel.on("user_joined", payload => {
console.log(`${payload.username} joined the room`)
})
channel.on("user_left", payload => {
console.log(`${payload.username} left the room`)
})
channel.on("presence_state", state => {
console.log("Presence state:", state)
})
channel.on("presence_diff", diff => {
console.log("Presence diff:", diff)
})
channel.join()
.receive("ok", resp => {
console.log("Joined room successfully", resp)
})
.receive("error", resp => {
console.log("Unable to join room", resp)
})
}
export default socket
Add Router Configuration
# lib/chat_app_web/router.ex
defmodule ChatAppWeb.Router do
use ChatAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ChatAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
scope "/", ChatAppWeb do
pipe_through :browser
get "/", PageController, :home
live "/chat/:slug", ChatLive
end
end
1. DynamoDB Optimization
Use BatchGetItem for Loading Multiple Items:
def get_users_batch(user_ids) do
keys = Enum.map(user_ids, fn id -> %{userId: id} end)
request_items = %{
@users_table => %{
keys: keys
}
}
case Dynamo.batch_get_item(request_items) |> ExAws.request() do
{:ok, %{"Responses" => responses}} ->
users =
Map.get(responses, @users_table, [])
|> Enum.map(&decode_user/1)
{:ok, users}
{:error, reason} ->
{:error, reason}
end
end
Use BatchWriteItem for Bulk Operations:
def create_messages_batch(messages_attrs) do
put_requests =
Enum.map(messages_attrs, fn attrs ->
message_id = UUID.uuid4()
timestamp = System.system_time(:millisecond)
item = %{
roomId: attrs.room_id,
timestamp: timestamp,
messageId: message_id,
userId: attrs.user_id,
username: attrs.username,
content: attrs.content,
createdAt: DateTime.utc_now() |> DateTime.to_iso8601()
}
%{put_request: %{item: item}}
end)
request_items = %{
@messages_table => put_requests
}
case Dynamo.batch_write_item(request_items) |> ExAws.request() do
{:ok, _response} -> :ok
{:error, reason} -> {:error, reason}
end
end
2. Caching with ETS
For frequently accessed data like room information:
# lib/chat_app/cache.ex
defmodule ChatApp.Cache do
use GenServer
@table_name :room_cache
@ttl :timer.minutes(5)
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(_) do
:ets.new(@table_name, [:named_table, :public, read_concurrency: true])
{:ok, %{}}
end
def get_room(room_id) do
case :ets.lookup(@table_name, room_id) do
[{^room_id, room, expires_at}] ->
if System.system_time(:millisecond) < expires_at do
{:ok, room}
else
:ets.delete(@table_name, room_id)
{:error, :expired}
end
[] ->
{:error, :not_found}
end
end
def put_room(room_id, room) do
expires_at = System.system_time(:millisecond) + @ttl
:ets.insert(@table_name, {room_id, room, expires_at})
:ok
end
def delete_room(room_id) do
:ets.delete(@table_name, room_id)
:ok
end
end
Add to your supervision tree:
# lib/chat_app/application.ex
children = [
# ...
ChatApp.Cache,
# ...
]
3. Rate Limiting
Implement rate limiting to prevent abuse:
# lib/chat_app_web/channels/room_channel.ex
defmodule ChatAppWeb.RoomChannel do
# ...
@rate_limit_window 10_000 # 10 seconds
@max_messages_per_window 20
def handle_in("new_message", params, socket) do
case check_rate_limit(socket) do
{:ok, socket} ->
create_and_broadcast_message(params, socket)
{:error, :rate_limited, socket} ->
{:reply, {:error, %{reason: "Too many messages. Please slow down."}}, socket}
end
end
defp check_rate_limit(socket) do
now = System.system_time(:millisecond)
recent_messages = socket.assigns[:recent_message_times] || []
# Filter messages within the window
messages_in_window =
Enum.filter(recent_messages, fn ts ->
now - ts < @rate_limit_window
end)
if length(messages_in_window) < @max_messages_per_window do
# Update socket with new timestamp
updated_times = [now | messages_in_window]
{:ok, assign(socket, :recent_message_times, updated_times)}
else
{:error, :rate_limited, socket}
end
end
defp create_and_broadcast_message(%{"content" => content}, socket) do
# ... existing message creation logic
end
end
4. Connection Pooling
Configure Hackney (HTTP client used by ex_aws) for optimal performance:
# config/config.exs
config :ex_aws, :hackney,
pool_timeout: 10_000,
recv_timeout: 30_000,
max_connections: 100
Step 7: Production Deployment
DynamoDB Best Practices
1. Enable Point-in-Time Recovery:
aws dynamodb update-continuous-backups \
--table-name ChatApp_Messages \
--point-in-time-recovery-specification \
PointInTimeRecoveryEnabled=true
2. Configure Auto Scaling (if not using PAY_PER_REQUEST):
# Register scalable target
aws application-autoscaling register-scalable-target \
--service-namespace dynamodb \
--resource-id "table/ChatApp_Messages" \
--scalable-dimension "dynamodb:table:WriteCapacityUnits" \
--min-capacity 5 \
--max-capacity 100
# Create scaling policy
aws application-autoscaling put-scaling-policy \
--service-namespace dynamodb \
--resource-id "table/ChatApp_Messages" \
--scalable-dimension "dynamodb:table:WriteCapacityUnits" \
--policy-name "ChatAppMessagesWriteScaling" \
--policy-type "TargetTrackingScaling" \
--target-tracking-scaling-policy-configuration \
"PredefinedMetricSpecification={PredefinedMetricType=DynamoDBWriteCapacityUtilization},TargetValue=70.0"
3. Enable Global Tables for Multi-Region:
# Create replica in another region
aws dynamodb update-table \
--table-name ChatApp_Messages \
--replica-updates \
'[{
"Create": {
"RegionName": "eu-west-1"
}
}]'
Deploy Phoenix Application
Using Docker:
# Dockerfile
FROM hexpm/elixir:1.15.7-erlang-26.1.2-alpine-3.18.4 AS build
# Install build dependencies
RUN apk add --no-cache build-base git nodejs npm
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
ENV MIX_ENV=prod
# Install dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
# Copy application
COPY config config
COPY priv priv
COPY lib lib
COPY assets assets
# Compile assets
RUN cd assets && npm install && npm run deploy
RUN mix phx.digest
# Compile application
RUN mix compile
# Build release
RUN mix release
# Runtime stage
FROM alpine:3.18.4
RUN apk add --no-cache openssl ncurses-libs libstdc++ libgcc
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app app
WORKDIR /app
COPY --from=build --chown=app:app /app/_build/prod/rel/chat_app ./
USER app
EXPOSE 4000
ENV HOME=/app
ENV MIX_ENV=prod
CMD ["bin/chat_app", "start"]
Build and deploy:
# Build
docker build -t chat-app:latest .
# Run with AWS credentials
docker run -p 4000:4000 \
-e SECRET_KEY_BASE="$(mix phx.gen.secret)" \
-e AWS_ACCESS_KEY_ID="your-access-key" \
-e AWS_SECRET_ACCESS_KEY="your-secret-key" \
-e AWS_REGION="us-east-1" \
chat-app:latest
Deploy to AWS ECS/Fargate
- Push Docker image to ECR:
# Create ECR repository
aws ecr create-repository --repository-name chat-app
# Login to ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
<account-id>.dkr.ecr.us-east-1.amazonaws.com
# Tag and push
docker tag chat-app:latest <account-id>.dkr.ecr.us-east-1.amazonaws.com/chat-app:latest
docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/chat-app:latest
- Create ECS Task Definition:
{
"family": "chat-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"containerDefinitions": [
{
"name": "chat-app",
"image": "<account-id>.dkr.ecr.us-east-1.amazonaws.com/chat-app:latest",
"portMappings": [
{
"containerPort": 4000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "AWS_REGION",
"value": "us-east-1"
},
{
"name": "PHX_HOST",
"value": "chat.yourdomain.com"
}
],
"secrets": [
{
"name": "SECRET_KEY_BASE",
"valueFrom": "arn:aws:secretsmanager:us-east-1:account-id:secret:chat-app-secret-key"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/chat-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
- Create ECS Service with Load Balancer
aws ecs create-service \
--cluster chat-app-cluster \
--service-name chat-app-service \
--task-definition chat-app:1 \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-xxx],securityGroups=[sg-xxx],assignPublicIp=ENABLED}" \
--load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:...,containerName=chat-app,containerPort=4000"
Step 8: Monitoring and Observability
Add CloudWatch Metrics
# lib/chat_app/telemetry.ex
defmodule ChatApp.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
def init(_arg) do
children = [
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond},
tags: [:route]
),
# Channel Metrics
counter("phoenix.channel_joined.count",
tags: [:channel]
),
summary("phoenix.channel_join.duration",
unit: {:native, :millisecond},
tags: [:channel]
),
# DynamoDB Metrics
counter("chat_app.dynamo.query.count",
tags: [:table, :operation]
),
summary("chat_app.dynamo.query.duration",
unit: {:native, :millisecond},
tags: [:table, :operation]
),
# Custom Metrics
counter("chat_app.messages.sent",
tags: [:room_id]
),
last_value("chat_app.connections.active")
]
end
defp periodic_measurements do
[
{ChatApp.Telemetry, :measure_active_connections, []}
]
end
def measure_active_connections do
# Count active Phoenix channel connections
# This is a simplified example
:telemetry.execute([:chat_app, :connections], %{active: 0}, %{})
end
end
Add DynamoDB Telemetry
# lib/chat_app/dynamo.ex
defmodule ChatApp.Dynamo do
# ...
defp execute_with_telemetry(operation, table, fun) do
start_time = System.monotonic_time()
result = fun.()
duration = System.monotonic_time() - start_time
:telemetry.execute(
[:chat_app, :dynamo, :query],
%{duration: duration},
%{table: table, operation: operation}
)
# Increment counter
:telemetry.execute(
[:chat_app, :dynamo, :query, :count],
%{count: 1},
%{table: table, operation: operation}
)
result
end
# Update methods to use telemetry
def get_user(user_id) do
execute_with_telemetry(:get_item, @users_table, fn ->
case Dynamo.get_item(@users_table, %{userId: user_id}) |> ExAws.request() do
{:ok, %{"Item" => item}} -> {:ok, decode_user(item)}
{:ok, %{}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end)
end
end
Cost Optimization
DynamoDB Pricing
DynamoDB offers two pricing models:
1. On-Demand (PAY_PER_REQUEST) - Best for unpredictable workloads:
- $1.25 per million write requests
- $0.25 per million read requests
- $0.25 per GB-month storage
2. Provisioned Capacity - Best for predictable workloads:
- $0.00065 per WCU-hour
- $0.00013 per RCU-hour
- $0.25 per GB-month storage
Example Cost Calculation (On-Demand):
Chat app with 10,000 active users:
- 50 messages per user per day = 500,000 messages/day
- Each message = 1 write (Messages table) + 1 read (display)
- Monthly writes: 500,000 Γ 30 = 15 million writes = $18.75/month
- Monthly reads: Assume 5x reads per message = 75 million reads = $18.75/month
- Storage: 500,000 messages Γ 1KB Γ 30 days = 15 GB = $3.75/month
- Total: ~$41.25/month
Compare to traditional database:
- RDS PostgreSQL db.t3.medium: ~$60/month (single AZ) + backup costs
- Requires manual scaling and maintenance
- Not globally distributed
Cost Optimization Tips:
- Use Time-To-Live (TTL) to auto-delete old messages:
aws dynamodb update-time-to-live \
--table-name ChatApp_Messages \
--time-to-live-specification \
"Enabled=true,AttributeName=expiresAt"
Update message creation to include TTL:
def create_message(attrs) do
# Set message to expire after 30 days
expires_at = DateTime.utc_now()
|> DateTime.add(30, :day)
|> DateTime.to_unix()
item = %{
# ... other attributes
expiresAt: expires_at
}
# ...
end
-
Use Reserved Capacity for predictable baseline traffic (40-75% discount)
-
Implement caching for frequently read data (ETS, Redis, etc.)
-
Batch operations when possible to reduce request count
Security Best Practices
defmodule ChatApp.MessageValidator do
@max_message_length 2000
@min_message_length 1
def validate_message(content) do
content = String.trim(content)
cond do
String.length(content) < @min_message_length ->
{:error, "Message cannot be empty"}
String.length(content) > @max_message_length ->
{:error, "Message is too long (max #{@max_message_length} characters)"}
contains_forbidden_content?(content) ->
{:error, "Message contains forbidden content"}
true ->
{:ok, sanitize_content(content)}
end
end
defp sanitize_content(content) do
content
|> HtmlSanitizeEx.strip_tags()
|> String.trim()
end
defp contains_forbidden_content?(content) do
# Implement content moderation logic
false
end
end
2. DynamoDB IAM Policies
Use least-privilege IAM policies:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:Scan"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:account-id:table/ChatApp_*"
]
},
{
"Effect": "Allow",
"Action": [
"dynamodb:Query"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:account-id:table/ChatApp_*/index/*"
]
}
]
}
3. Enable DynamoDB Encryption
# Enable encryption at rest (enabled by default for new tables)
aws dynamodb update-table \
--table-name ChatApp_Messages \
--sse-specification Enabled=true,SSEType=KMS
4. WebSocket Authentication
Implement proper JWT authentication:
# lib/chat_app_web/channels/user_socket.ex
defmodule ChatAppWeb.UserSocket do
use Phoenix.Socket
@max_age 86400 # 24 hours
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
case verify_jwt_token(token) do
{:ok, user_id, username} ->
{:ok, assign(socket, user_id: user_id, username: username)}
{:error, _reason} ->
:error
end
end
def connect(_params, _socket, _connect_info), do: :error
defp verify_jwt_token(token) do
# Use a proper JWT library like `joken`
case Joken.verify_and_validate(token, secret_key()) do
{:ok, %{"user_id" => user_id, "username" => username}} ->
{:ok, user_id, username}
{:error, _} ->
{:error, :invalid_token}
end
end
defp secret_key do
Application.get_env(:chat_app, :secret_key_base)
end
end
Advanced Features
1. Message Reactions (DynamoDB Streams + Lambda)
Enable DynamoDB Streams to trigger Lambda functions for secondary processing:
# Enable streams
aws dynamodb update-table \
--table-name ChatApp_Messages \
--stream-specification \
StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
Process stream events with Lambda for features like:
- Message notifications
- Search indexing (Elasticsearch/OpenSearch)
- Analytics processing
- Message reactions/emoji counts
2. Read Receipts
Add a ReadReceipts table:
aws dynamodb create-table \
--table-name ChatApp_ReadReceipts \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=roomId,AttributeType=S \
--key-schema \
AttributeName=userId,KeyType=HASH \
AttributeName=roomId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST
Track last read timestamp per user per room:
def update_read_receipt(user_id, room_id) do
item = %{
userId: user_id,
roomId: room_id,
lastReadAt: System.system_time(:millisecond),
updatedAt: DateTime.utc_now() |> DateTime.to_iso8601()
}
Dynamo.put_item("ChatApp_ReadReceipts", item) |> ExAws.request()
end
3. File Uploads (S3 Integration)
Use S3 for file attachments:
defmodule ChatApp.FileUpload do
def upload_file(file, room_id, user_id) do
bucket = "chat-app-uploads"
key = "#{room_id}/#{user_id}/#{UUID.uuid4()}-#{file.filename}"
# Generate presigned URL for direct upload
{:ok, presigned_url} =
ExAws.S3.presigned_url(
ExAws.Config.new(:s3),
:put,
bucket,
key,
expires_in: 3600
)
{:ok, %{upload_url: presigned_url, file_key: key}}
end
def get_file_url(file_key) do
bucket = "chat-app-uploads"
ExAws.S3.presigned_url(
ExAws.Config.new(:s3),
:get,
bucket,
file_key,
expires_in: 3600
)
end
end
Conclusion
Building a real-time chat application with Elixir, Phoenix, and DynamoDB combines the best of modern cloud-native architecture:
β
Elixir/Phoenix: Industry-leading concurrency, real-time capabilities, fault tolerance
β
DynamoDB: Serverless scalability, global distribution, pay-per-use pricing
β
Production-Ready: Battle-tested technologies used by companies like Discord, Amazon, and thousands of others
Key Takeaways
- Phoenix Channels provide robust, production-ready WebSocket infrastructure
- DynamoDB scales effortlessly from zero to millions of requests per second
- Pay-per-use pricing means you only pay for what you actually use
- Global tables enable low-latency access worldwide
- Serverless architecture reduces operational overhead
When This Stack Excels
β
Perfect for:
- Real-time chat and messaging platforms
- Collaborative tools and workspaces
- Live customer support systems
- Gaming chat and social features
- IoT device messaging
- Applications requiring global distribution
- Startups needing to scale cost-effectively
β οΈ Consider alternatives for:
- Complex relational queries (use RDS/PostgreSQL)
- Strong ACID transaction requirements across multiple entities
- Very tight budget constraints with low traffic (consider PostgreSQL)
Next Steps
- Clone the code and customize for your use case
- Add authentication with proper JWT tokens
- Implement notifications with DynamoDB Streams + Lambda
- Add file uploads with S3 integration
- Deploy globally with DynamoDB Global Tables
- Monitor and optimize with CloudWatch metrics
- Scale confidently - the stack handles millions of users
The combination of Elixirβs concurrency model and DynamoDBβs serverless scalability gives you a foundation to build chat applications that can grow from zero to millions of users without major architectural changes.
Ready to build your real-time messaging platform? Contact Async Squad Labs for expert help architecting and deploying production-grade Elixir/Phoenix applications with AWS DynamoDB and other cloud-native services.
Related Articles
Our team of experienced software engineers specializes in building scalable applications with Elixir, Python, Go, and modern AI technologies. We help companies ship better software faster.
π¬ Stay Updated with Our Latest Insights
Get expert tips on software development, AI integration, and best practices delivered to your inbox. Join our community of developers and tech leaders.