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:
- User types a message in the React Native app
- Client pushes message to Phoenix Channel via WebSocket
- Channel authenticates the action and validates payload
- Channel broadcasts message to all subscribed users via PubSub
- PubSub distributes to all server nodes in the cluster
- Each node pushes to its locally-connected clients
- React Native clients receive message and update UI
- 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.