1 min read

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:

  1. Phoenix Channels for Real-Time: Handles all WebSocket connections and message broadcasting
  2. DynamoDB for Persistence: Stores users, rooms, and message history with infinite scalability
  3. Phoenix Clustering: Multiple Phoenix nodes share load and provide redundancy
  4. Global Tables (Optional): DynamoDB replication across AWS regions for global low latency
  5. 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.

Configure AWS Credentials

# 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:

  1. Get user by userId
  2. Get room by roomId
  3. Get all messages in a room (paginated, newest first)
  4. Get all messages by a user
  5. Get recent messages in a room

Tables:

  1. Users Table

    • Partition Key: userId (String)
    • Attributes: username, email, passwordHash, createdAt
  2. Rooms Table

    • Partition Key: roomId (String)
    • Attributes: name, description, slug, createdAt
    • GSI: slug-index (for lookup by slug)
  3. 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

Configure User Socket

# 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

Step 6: Performance Optimization

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

  1. 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
  1. 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"
        }
      }
    }
  ]
}
  1. 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:

  1. 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
  1. Use Reserved Capacity for predictable baseline traffic (40-75% discount)

  2. Implement caching for frequently read data (ETS, Redis, etc.)

  3. Batch operations when possible to reduce request count

Security Best Practices

1. Input Validation and Sanitization

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

  1. Phoenix Channels provide robust, production-ready WebSocket infrastructure
  2. DynamoDB scales effortlessly from zero to millions of requests per second
  3. Pay-per-use pricing means you only pay for what you actually use
  4. Global tables enable low-latency access worldwide
  5. 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

  1. Clone the code and customize for your use case
  2. Add authentication with proper JWT tokens
  3. Implement notifications with DynamoDB Streams + Lambda
  4. Add file uploads with S3 integration
  5. Deploy globally with DynamoDB Global Tables
  6. Monitor and optimize with CloudWatch metrics
  7. 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.

Async Squad Labs Team

Async Squad Labs Team

Software Engineering Experts

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.