Elixir with React Native: A Powerful Combo for Real-Time Mobile Apps
[ Mobile Development ]

Elixir with React Native: A Powerful Combo for Real-Time Mobile Apps

Master real-time mobile development by combining Phoenix Channels with React Native/Expo. Complete guide covering WebSocket architecture, authentication, offline resilience, scaling patterns, and production deployment strategies.

→ featured
→ essential
→ timely
By Paul Badarau 20 min read
[ Article Content ]
Share this article:
P
Paul Badarau
Author

Elixir with React Native: The Complete Guide to Building Real-Time Mobile Applications

Meta Description: Master real-time mobile development by combining Phoenix Channels with React Native/Expo. Complete guide covering WebSocket architecture, authentication, offline resilience, scaling patterns, and production deployment strategies.

The Hook: Real-Time Features That Actually Work

We've all used messaging apps or live tracking features that feel magical—instant updates, seamless reconnections, smooth transitions between online and offline states. Then we've tried to build them and discovered the brutal reality: WebSockets drop constantly, state synchronization is tricky, offline queuing is complex, and scaling introduces race conditions.

The combination of Elixir's Phoenix Channels and React Native (particularly with Expo) offers a remarkably elegant solution to these problems. Phoenix Channels leverage the BEAM VM's legendary concurrency model to handle millions of concurrent connections on modest hardware. React Native provides a smooth mobile development experience with hot reload and cross-platform deployment. Together, they form a stack that makes real-time mobile features achievable without massive infrastructure complexity.

This comprehensive guide walks through the complete architecture needed for production-ready real-time mobile applications. We'll cover Phoenix Channels setup with authentication and authorization, React Native/Expo client implementation with TypeScript, offline resilience and reconnection patterns, message queuing and ordering guarantees, presence tracking and user state, horizontal scaling with distributed Elixir, and monitoring with Telemetry instrumentation.

By the end, you'll have the knowledge to build chat applications, live collaboration tools, real-time gaming features, live location tracking, and multiplayer experiences that work reliably in production.

Why Elixir Excels at Real-Time Features

The BEAM VM's Concurrency Model

Elixir runs on the Erlang VM (BEAM), which was designed from the ground up for telecom systems requiring millions of concurrent connections with microsecond latency. The architecture provides:

  • Lightweight processes: Each WebSocket connection runs in an isolated process consuming just a few KB of memory
  • Pre-emptive scheduling: The scheduler prevents any single process from starving others
  • Actor model: Processes communicate via message passing—no shared state or locks
  • Fault tolerance: Process crashes are isolated and don't affect other connections
  • Hot code swapping: Deploy updates without dropping connections

This means a single Elixir server on modest hardware (8 cores, 16GB RAM) can handle 100,000+ concurrent WebSocket connections while maintaining sub-10ms message latency.

Phoenix Channels: WebSockets Made Simple

Phoenix Channels abstract WebSocket complexity behind a clean API. Key features:

  • Topic-based routing: Clients subscribe to topics (like "chat:lobby" or "game:match:123")
  • Automatic failover: Long-polling fallback if WebSockets aren't available
  • Built-in presence: Track who's connected to each topic with conflict-free state
  • Message multiplexing: Single WebSocket connection handles multiple topic subscriptions
  • Fault recovery: Reconnection and message replay built-in

You write business logic focused on "when user joins room" or "when message arrives"—the framework handles the networking complexity.

Production-Grade from Day One

Phoenix Channels aren't experimental—they power production applications at scale. Pinterest runs real-time notifications on Phoenix. Discord built their initial MVP on it. The architecture has been battle-tested under extreme load and has proven patterns for horizontal scaling.

Architecture Overview: How the Pieces Fit Together

System Components

A typical real-time mobile application built with Elixir and React Native consists of:

Backend (Elixir/Phoenix):

  • Phoenix Endpoint: Handles WebSocket upgrades and routing
  • UserSocket: Authenticates connections and assigns user context
  • Channels: Business logic for each topic (chat, presence, game state, etc.)
  • PubSub: Distributes messages across server nodes
  • Presence: Tracks connected users with CRDT-based state
  • Database: Persists messages, user data, and application state

Frontend (React Native/Expo):

  • Socket client: Manages WebSocket connection lifecycle
  • Channel subscriptions: Topic-specific message handling
  • Offline queue: Buffers outbound messages when disconnected
  • State management: Zustand/Redux for application state
  • UI components: Native mobile interface
  • Push notifications: Background message delivery via Firebase/APNs

Infrastructure:

  • Load balancer: Distributes connections (sticky sessions required)
  • Distributed Elixir cluster: Scales horizontally across nodes
  • Redis/PostgreSQL: PubSub backend for clustering
  • Monitoring: Telemetry, metrics, and error tracking

Message Flow

Here's how a typical message flows through the system:

  1. User types a message in the React Native app
  2. Client pushes message to Phoenix Channel via WebSocket
  3. Channel authenticates the action and validates payload
  4. Channel broadcasts message to all subscribed users via PubSub
  5. PubSub distributes to all server nodes in the cluster
  6. Each node pushes to its locally-connected clients
  7. React Native clients receive message and update UI
  8. Offline clients receive via push notification

The entire flow completes in 10-50ms depending on geographic distribution.

Phoenix Channels Implementation: Backend Setup

Project Setup and Dependencies

Create a new Phoenix project with Channel support:

mix phx.new realtime_app --no-html --no-assets
cd realtime_app

Your mix.exs should include:

defp deps do
  [
    {:phoenix, "~> 1.7"},
    {:phoenix_pubsub, "~> 2.1"},
    {:jason, "~> 1.4"},
    {:plug_cowboy, "~> 2.6"},
    {:cors_plug, "~> 3.0"}, # For CORS if needed
    {:joken, "~> 2.6"} # For JWT authentication
  ]
end

UserSocket: Authentication Layer

The UserSocket authenticates connections and assigns user context:

# lib/realtime_app_web/channels/user_socket.ex
defmodule RealtimeAppWeb.UserSocket do
  use Phoenix.Socket

  # Define available channels
  channel "room:*", RealtimeAppWeb.RoomChannel
  channel "presence:*", RealtimeAppWeb.PresenceChannel
  channel "game:*", RealtimeAppWeb.GameChannel

  @impl true
  def connect(%{"token" => token}, socket, _connect_info) do
    case verify_token(token) do
      {:ok, user_id} ->
        # Assign user context to socket
        socket = assign(socket, :user_id, user_id)
        socket = assign(socket, :connected_at, System.system_time(:millisecond))
        {:ok, socket}
      
      {:error, _reason} ->
        :error
    end
  end

  def connect(_params, _socket, _connect_info), do: :error

  @impl true
  def id(socket), do: "user:#{socket.assigns.user_id}"

  defp verify_token(token) do
    # Use Joken or similar to verify JWT
    case Joken.verify_and_validate(MyApp.Token, token) do
      {:ok, %{"user_id" => user_id}} -> {:ok, user_id}
      {:error, reason} -> {:error, reason}
    end
  end
end

This pattern ensures every WebSocket connection is authenticated before any messages flow.

RoomChannel: Basic Message Handling

Implement a chat room channel with authorization:

# lib/realtime_app_web/channels/room_channel.ex
defmodule RealtimeAppWeb.RoomChannel do
  use Phoenix.Channel
  require Logger

  @impl true
  def join("room:" <> room_id, _params, socket) do
    # Authorize user access to this room
    if authorized?(socket.assigns.user_id, room_id) do
      # Track user presence
      send(self(), :after_join)
      
      # Assign room context
      socket = assign(socket, :room_id, room_id)
      
      # Send recent message history
      {:ok, %{messages: recent_messages(room_id)}, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  @impl true
  def handle_in("new_message", %{"body" => body}, socket) do
    user_id = socket.assigns.user_id
    room_id = socket.assigns.room_id
    
    # Validate message
    with {:ok, validated} <- validate_message(body),
         {:ok, message} <- persist_message(user_id, room_id, validated) do
      
      # Broadcast to all room members
      broadcast!(socket, "new_message", %{
        id: message.id,
        user_id: user_id,
        body: message.body,
        inserted_at: message.inserted_at
      })
      
      {:reply, {:ok, %{message_id: message.id}}, socket}
    else
      {:error, reason} ->
        {:reply, {:error, %{reason: reason}}, socket}
    end
  end

  @impl true
  def handle_in("typing", _params, socket) do
    broadcast_from!(socket, "user_typing", %{
      user_id: socket.assigns.user_id
    })
    {:noreply, socket}
  end

  @impl true
  def handle_info(:after_join, socket) do
    # Track presence after join completes
    {:ok, _} = RealtimeAppWeb.Presence.track(socket, socket.assigns.user_id, %{
      online_at: System.system_time(:millisecond),
      user_id: socket.assigns.user_id
    })
    
    push(socket, "presence_state", RealtimeAppWeb.Presence.list(socket))
    {:noreply, socket}
  end

  defp authorized?(user_id, room_id) do
    # Implement your authorization logic
    MyApp.Rooms.user_can_access?(user_id, room_id)
  end

  defp validate_message(body) when is_binary(body) and byte_size(body) > 0 and byte_size(body) < 5000 do
    {:ok, String.trim(body)}
  end
  defp validate_message(_), do: {:error, "invalid_message"}

  defp persist_message(user_id, room_id, body) do
    MyApp.Messages.create_message(%{
      user_id: user_id,
      room_id: room_id,
      body: body
    })
  end

  defp recent_messages(room_id) do
    MyApp.Messages.list_recent(room_id, limit: 50)
  end
end

Presence Tracking

Phoenix Presence provides distributed, eventually-consistent user tracking:

# lib/realtime_app_web/channels/presence.ex
defmodule RealtimeAppWeb.Presence do
  use Phoenix.Presence,
    otp_app: :realtime_app,
    pubsub_server: RealtimeApp.PubSub
end

Enable in your application supervisor:

# lib/realtime_app/application.ex
def start(_type, _args) do
  children = [
    RealtimeApp.Repo,
    {Phoenix.PubSub, name: RealtimeApp.PubSub},
    RealtimeAppWeb.Presence,
    RealtimeAppWeb.Endpoint
  ]

  opts = [strategy: :one_for_one, name: RealtimeApp.Supervisor]
  Supervisor.start_link(children, opts)
end

Clients can now query presence:

# Get all users in a room
RealtimeAppWeb.Presence.list("room:lobby")
#=> %{
#  "user:123" => %{metas: [%{online_at: 1234567890, user_id: 123}]},
#  "user:456" => %{metas: [%{online_at: 1234567900, user_id: 456}]}
#}

Rate Limiting and Backpressure

Protect your system from abusive clients:

# lib/realtime_app_web/channels/room_channel.ex
defmodule RealtimeAppWeb.RoomChannel do
  use Phoenix.Channel
  
  @max_messages_per_minute 60

  def handle_in("new_message", payload, socket) do
    socket = track_rate_limit(socket)
    
    if rate_limit_exceeded?(socket) do
      {:reply, {:error, %{reason: "rate_limit_exceeded"}}, socket}
    else
      # Process message normally
      handle_message(payload, socket)
    end
  end

  defp track_rate_limit(socket) do
    now = System.system_time(:second)
    timestamps = socket.assigns[:message_timestamps] || []
    
    # Keep only messages from last minute
    recent = Enum.filter(timestamps, fn ts -> now - ts < 60 end)
    
    assign(socket, :message_timestamps, [now | recent])
  end

  defp rate_limit_exceeded?(socket) do
    count = length(socket.assigns[:message_timestamps] || [])
    count > @max_messages_per_minute
  end
end

React Native Client Implementation

Socket Connection Management

Create a robust socket manager with automatic reconnection:

// lib/socket.ts
import { Socket, Channel } from 'phoenix';
import * as SecureStore from 'expo-secure-store';

export class SocketManager {
  private socket: Socket | null = null;
  private channels: Map<string, Channel> = new Map();
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;

  async connect() {
    const token = await SecureStore.getItemAsync('auth_token');
    if (!token) {
      throw new Error('No authentication token found');
    }

    this.socket = new Socket('wss://api.example.com/socket', {
      params: { token },
      logger: __DEV__ ? console.log : undefined,
      reconnectAfterMs: (tries) => {
        // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s...
        return Math.min(1000 * Math.pow(2, tries), 30000);
      },
    });

    this.socket.onOpen(() => {
      console.log('Socket connected');
      this.reconnectAttempts = 0;
      this.notifyConnectionState('connected');
    });

    this.socket.onError((error) => {
      console.error('Socket error:', error);
      this.notifyConnectionState('error');
    });

    this.socket.onClose(() => {
      console.log('Socket closed');
      this.notifyConnectionState('disconnected');
      
      if (this.reconnectAttempts++ < this.maxReconnectAttempts) {
        console.log(`Reconnecting (attempt ${this.reconnectAttempts})`);
      } else {
        console.error('Max reconnection attempts reached');
        this.notifyConnectionState('failed');
      }
    });

    this.socket.connect();
  }

  joinChannel(topic: string, onMessage: (event: string, payload: any) => void) {
    if (!this.socket) {
      throw new Error('Socket not connected');
    }

    const channel = this.socket.channel(topic, {});
    
    channel.join()
      .receive('ok', (resp) => {
        console.log(`Joined ${topic}`, resp);
      })
      .receive('error', (resp) => {
        console.error(`Failed to join ${topic}`, resp);
      })
      .receive('timeout', () => {
        console.error(`Timeout joining ${topic}`);
      });

    // Set up message handlers
    channel.on('new_message', (payload) => onMessage('new_message', payload));
    channel.on('user_typing', (payload) => onMessage('user_typing', payload));
    channel.on('presence_state', (payload) => onMessage('presence_state', payload));
    channel.on('presence_diff', (payload) => onMessage('presence_diff', payload));

    this.channels.set(topic, channel);
    return channel;
  }

  leaveChannel(topic: string) {
    const channel = this.channels.get(topic);
    if (channel) {
      channel.leave();
      this.channels.delete(topic);
    }
  }

  pushMessage(topic: string, event: string, payload: any): Promise<any> {
    const channel = this.channels.get(topic);
    if (!channel) {
      throw new Error(`Not joined to channel: ${topic}`);
    }

    return new Promise((resolve, reject) => {
      channel.push(event, payload, 10000)
        .receive('ok', resolve)
        .receive('error', reject)
        .receive('timeout', () => reject(new Error('Request timeout')));
    });
  }

  disconnect() {
    this.channels.forEach((channel) => channel.leave());
    this.channels.clear();
    
    if (this.socket) {
      this.socket.disconnect();
      this.socket = null;
    }
  }

  private notifyConnectionState(state: 'connected' | 'disconnected' | 'error' | 'failed') {
    // Emit event to app-wide event bus or state management
    // This allows UI to react to connection status
  }
}

export const socketManager = new SocketManager();

Offline Message Queue

Queue outbound messages when offline and send when reconnected:

// lib/offline-queue.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

interface QueuedMessage {
  id: string;
  topic: string;
  event: string;
  payload: any;
  timestamp: number;
  retries: number;
}

export class OfflineQueue {
  private queue: QueuedMessage[] = [];
  private readonly STORAGE_KEY = '@offline_queue';
  private readonly MAX_RETRIES = 3;

  async init() {
    const stored = await AsyncStorage.getItem(this.STORAGE_KEY);
    if (stored) {
      this.queue = JSON.parse(stored);
    }
  }

  async enqueue(topic: string, event: string, payload: any) {
    const message: QueuedMessage = {
      id: `${Date.now()}-${Math.random()}`,
      topic,
      event,
      payload,
      timestamp: Date.now(),
      retries: 0,
    };

    this.queue.push(message);
    await this.persist();
    return message.id;
  }

  async flush(socketManager: SocketManager) {
    const toSend = [...this.queue];
    this.queue = [];

    for (const message of toSend) {
      try {
        await socketManager.pushMessage(message.topic, message.event, message.payload);
        console.log(`Sent queued message ${message.id}`);
      } catch (error) {
        console.error(`Failed to send queued message ${message.id}:`, error);
        
        if (message.retries < this.MAX_RETRIES) {
          message.retries++;
          this.queue.push(message);
        }
      }
    }

    await this.persist();
  }

  private async persist() {
    await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.queue));
  }
}

export const offlineQueue = new OfflineQueue();

Chat Component Example

A complete React Native chat component:

// components/ChatRoom.tsx
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
import { socketManager } from '../lib/socket';
import { offlineQueue } from '../lib/offline-queue';
import { useNetInfo } from '@react-native-community/netinfo';

interface Message {
  id: string;
  user_id: number;
  body: string;
  inserted_at: string;
}

export default function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputText, setInputText] = useState('');
  const [isOnline, setIsOnline] = useState(true);
  const netInfo = useNetInfo();
  const channelRef = useRef<any>(null);

  useEffect(() => {
    setIsOnline(netInfo.isConnected ?? false);
  }, [netInfo.isConnected]);

  useEffect(() => {
    // Join channel
    const channel = socketManager.joinChannel(`room:${roomId}`, handleChannelMessage);
    channelRef.current = channel;

    // Cleanup
    return () => {
      socketManager.leaveChannel(`room:${roomId}`);
    };
  }, [roomId]);

  useEffect(() => {
    // Flush offline queue when coming online
    if (isOnline && channelRef.current) {
      offlineQueue.flush(socketManager);
    }
  }, [isOnline]);

  const handleChannelMessage = (event: string, payload: any) => {
    if (event === 'new_message') {
      setMessages((prev) => [...prev, payload]);
    }
  };

  const sendMessage = async () => {
    if (!inputText.trim()) return;

    const tempId = `temp-${Date.now()}`;
    const optimisticMessage: Message = {
      id: tempId,
      user_id: 0, // Current user ID
      body: inputText,
      inserted_at: new Date().toISOString(),
    };

    // Optimistic UI update
    setMessages((prev) => [...prev, optimisticMessage]);
    setInputText('');

    try {
      if (isOnline) {
        const response = await socketManager.pushMessage(
          `room:${roomId}`,
          'new_message',
          { body: inputText }
        );
        
        // Replace temp message with real one
        setMessages((prev) =>
          prev.map((msg) =>
            msg.id === tempId ? { ...msg, id: response.message_id } : msg
          )
        );
      } else {
        // Queue for later
        await offlineQueue.enqueue(`room:${roomId}`, 'new_message', { body: inputText });
      }
    } catch (error) {
      console.error('Failed to send message:', error);
      // Revert optimistic update or show error
      setMessages((prev) => prev.filter((msg) => msg.id !== tempId));
    }
  };

  return (
    <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding">
      {!isOnline && (
        <View style={{ padding: 8, backgroundColor: '#ff9800' }}>
          <Text>Offline - messages will send when reconnected</Text>
        </View>
      )}
      
      <FlatList
        data={messages}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <View style={{ padding: 12, borderBottomWidth: 1, borderColor: '#eee' }}>
            <Text style={{ fontWeight: 'bold' }}>User {item.user_id}</Text>
            <Text>{item.body}</Text>
          </View>
        )}
      />
      
      <View style={{ flexDirection: 'row', padding: 8 }}>
        <TextInput
          style={{ flex: 1, borderWidth: 1, borderRadius: 8, padding: 8, marginRight: 8 }}
          value={inputText}
          onChangeText={setInputText}
          placeholder="Type a message..."
        />
        <TouchableOpacity
          onPress={sendMessage}
          style={{ padding: 12, backgroundColor: '#007AFF', borderRadius: 8 }}
        >
          <Text style={{ color: '#fff' }}>Send</Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
}

TypeScript Type Safety

Define strict types for channel events:

// types/channel-events.ts
export interface ChannelEvents {
  'new_message': {
    id: string;
    user_id: number;
    body: string;
    inserted_at: string;
  };
  'user_typing': {
    user_id: number;
  };
  'presence_state': {
    [userId: string]: {
      metas: Array<{
        online_at: number;
        user_id: number;
      }>;
    };
  };
  'presence_diff': {
    joins: Record<string, any>;
    leaves: Record<string, any>;
  };
}

export type ChannelEvent = keyof ChannelEvents;
export type ChannelPayload<E extends ChannelEvent> = ChannelEvents[E];

Use in typed channel subscriptions:

channel.on<'new_message'>('new_message', (payload: ChannelPayload<'new_message'>) => {
  // TypeScript knows payload structure
  console.log(payload.body);
});

Authentication and Security

JWT Token Management

Implement secure token storage and refresh:

// lib/auth.ts
import * as SecureStore from 'expo-secure-store';
import axios from 'axios';

const API_URL = 'https://api.example.com';

export async function login(email: string, password: string) {
  const response = await axios.post(`${API_URL}/auth/login`, {
    email,
    password,
  });

  const { access_token, refresh_token } = response.data;
  
  await SecureStore.setItemAsync('auth_token', access_token);
  await SecureStore.setItemAsync('refresh_token', refresh_token);
  
  return response.data;
}

export async function refreshAuthToken() {
  const refreshToken = await SecureStore.getItemAsync('refresh_token');
  if (!refreshToken) {
    throw new Error('No refresh token available');
  }

  const response = await axios.post(`${API_URL}/auth/refresh`, {
    refresh_token: refreshToken,
  });

  const { access_token } = response.data;
  await SecureStore.setItemAsync('auth_token', access_token);
  
  return access_token;
}

export async function getAuthToken(): Promise<string | null> {
  return await SecureStore.getItemAsync('auth_token');
}

Server-Side Authorization

Validate channel operations on the server:

# lib/realtime_app_web/channels/room_channel.ex
def handle_in("delete_message", %{"message_id" => message_id}, socket) do
  user_id = socket.assigns.user_id
  
  case MyApp.Messages.get_message(message_id) do
    %{user_id: ^user_id} = message ->
      # User owns this message - allow deletion
      MyApp.Messages.delete_message(message)
      broadcast!(socket, "message_deleted", %{message_id: message_id})
      {:reply, :ok, socket}
    
    %{} ->
      # Message exists but user doesn't own it
      {:reply, {:error, %{reason: "unauthorized"}}, socket}
    
    nil ->
      {:reply, {:error, %{reason: "not_found"}}, socket}
  end
end

Rate Limiting at the Socket Level

Apply rate limits to prevent abuse:

# lib/realtime_app_web/channels/user_socket.ex
def connect(params, socket, connect_info) do
  ip = get_connect_ip(connect_info)
  
  case check_rate_limit(ip) do
    :ok ->
      # Proceed with normal authentication
      authenticate_connection(params, socket)
    
    {:error, :rate_limited} ->
      :error
  end
end

defp check_rate_limit(ip) do
  # Use a library like Hammer for distributed rate limiting
  case Hammer.check_rate("socket:#{ip}", 60_000, 100) do
    {:allow, _count} -> :ok
    {:deny, _limit} -> {:error, :rate_limited}
  end
end

Scaling Horizontally with Distributed Elixir

Clustering Configuration

Set up a distributed Elixir cluster for horizontal scaling:

# mix.exs
defp deps do
  [
    {:libcluster, "~> 3.3"},
    # other deps...
  ]
end

# lib/realtime_app/application.ex
def start(_type, _args) do
  topologies = [
    example: [
      strategy: Cluster.Strategy.Gossip,
      config: [
        port: 45892,
        if_addr: "0.0.0.0",
        multicast_addr: "230.1.1.251",
        multicast_ttl: 1,
        secret: System.get_env("CLUSTER_SECRET") || "default_secret"
      ]
    ]
  ]

  children = [
    {Cluster.Supervisor, [topologies, [name: RealtimeApp.ClusterSupervisor]]},
    RealtimeApp.Repo,
    {Phoenix.PubSub, name: RealtimeApp.PubSub},
    RealtimeAppWeb.Presence,
    RealtimeAppWeb.Endpoint
  ]

  opts = [strategy: :one_for_one, name: RealtimeApp.Supervisor]
  Supervisor.start_link(children, opts)
end

Load Balancer Configuration

Configure sticky sessions in your load balancer (nginx example):

upstream phoenix {
  ip_hash; # Sticky sessions based on IP
  server 10.0.1.10:4000;
  server 10.0.1.11:4000;
  server 10.0.1.12:4000;
}

server {
  listen 443 ssl http2;
  server_name api.example.com;

  location /socket {
    proxy_pass http://phoenix;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 86400;
  }
}

PubSub Backend for Clustering

Use Redis or PostgreSQL as PubSub backend:

# config/prod.exs
config :realtime_app, RealtimeApp.PubSub,
  adapter: Phoenix.PubSub.Redis,
  host: System.get_env("REDIS_HOST") || "localhost",
  port: String.to_integer(System.get_env("REDIS_PORT") || "6379")

Messages broadcast on one node now reach clients connected to any node in the cluster.

Monitoring and Observability

Telemetry Instrumentation

Add comprehensive telemetry:

# lib/realtime_app_web/telemetry.ex
defmodule RealtimeAppWeb.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
    [
      # Channel metrics
      counter("phoenix.channel_joined.stop.duration"),
      distribution("phoenix.channel_joined.stop.duration", unit: {:native, :millisecond}),
      
      # Socket metrics
      last_value("vm.memory.total", unit: {:byte, :megabyte}),
      last_value("vm.total_run_queue_lengths.total"),
      last_value("vm.total_run_queue_lengths.cpu"),
      
      # Custom metrics
      counter("realtime_app.messages.sent"),
      counter("realtime_app.messages.failed"),
      distribution("realtime_app.message_latency", unit: {:native, :millisecond})
    ]
  end

  defp periodic_measurements do
    []
  end
end

Dashboards and Alerts

Set up monitoring dashboards for:

  • Connection count: Total WebSocket connections per node
  • Message throughput: Messages/second per channel
  • Latency: P50, P95, P99 message delivery time
  • Error rate: Failed channel joins, message pushes
  • Presence churn: Joins/leaves per minute
  • Memory usage: Per-node memory consumption
  • CPU utilization: Per-node CPU usage

Alert on:

  • Connection count exceeding 80% of capacity
  • Error rate >1% of total messages
  • P95 latency >100ms
  • Any node down or unreachable

Production Deployment Checklist

Before going live with your real-time mobile application:

Backend Checklist

Frontend Checklist

Infrastructure Checklist

Conclusion: Real-Time Features Made Reliable

Building real-time mobile applications with Elixir and React Native provides a robust, scalable foundation that handles the hard problems—connection management, offline resilience, horizontal scaling, and fault tolerance—so you can focus on delivering great user experiences.

Phoenix Channels abstract WebSocket complexity while giving you fine-grained control over authentication, authorization, and message flow. The BEAM VM's concurrency model means you can handle massive connection counts without infrastructure complexity. React Native with Expo provides a smooth development experience with excellent mobile performance.

The patterns we've covered—offline queuing, automatic reconnection, optimistic updates, presence tracking, and horizontal scaling—are production-proven across thousands of applications. Implement them methodically, instrument comprehensively, and test thoroughly. The result will be real-time features that feel magical to users while remaining reliable under load.

For applications requiring chat, collaboration, live tracking, or multiplayer functionality, this stack eliminates the need for expensive third-party real-time services while giving you complete control over the architecture. Align backend patterns with /blog/rails-api-best-practices and mobile auth with /blog/rails-expo-integration for a cohesive full-stack approach.

Take the Next Step

Need to build real-time features that won't collapse under load? Elaris can design your Phoenix Channels architecture, implement offline-resilient React Native clients, set up horizontally-scaling infrastructure, and provide ongoing support as your user base grows.

We've shipped real-time applications handling millions of messages daily across chat platforms, collaboration tools, and multiplayer games. Our team can embed with yours to architect the solution, implement core patterns, and ensure your monitoring catches issues before users do.

Contact us to schedule a real-time architecture consultation and start building features your users will love.

[ Let's Build Together ]

Ready to transform your
business with software?

From strategy to implementation, we craft digital products that drive real business outcomes. Let's discuss your vision.

Related Topics:
Elixir Phoenix React Native Expo real-time 2025