A small, embeddable chat server built with Elixir + Phoenix and Erlang, running on the BEAM VM.
Supports hundreds of thousands of concurrent connections thanks to BEAM's lightweight process model, lock-free ETS-based rate limiting, and Phoenix's PubSub fanout.
| Layer | Technology |
|---|---|
| Language | Elixir 1.19, Erlang/OTP 26 |
| HTTP / WebSocket | Phoenix 1.8 + Bandit |
| Real-time | Phoenix Channels + Phoenix.Presence |
| Database | PostgreSQL via Ecto |
| Auth | Guardian (JWT) + Pbkdf2 password hashing |
| Rate limiting | Custom Erlang ETS module (erlex_rate_limiter) |
| Message stats | Custom Erlang ETS counter module (erlex_msg_stats) |
| Frontend | Next.js 16 in web/ |
# 1. Install dependencies
mix deps.get
# 2. Create & migrate database
mix ecto.setup
# 3. Start the backend
mix phx.server
# 4. In another terminal, start the web frontend
cd web
npm install
npm run devOpen http://localhost:3000 for the user-facing frontend.
PostgreSQL: Make sure Postgres is running. Default credentials:
postgres / postgresonlocalhost:5432.
Change them inconfig/dev.exsif needed.
Browser / External App
│
├── Next.js frontend → /web (separate app)
│ │
│ ├── REST proxy → /api/chat/* (Next route handler)
│ └── Phoenix WS → /chat/socket (JWT-authenticated)
│
├── REST API → /api/v1/* (Phoenix JSON)
│
└── WebSocket Channel → /chat/socket (Phoenix Channels)
│
▼
Phoenix PubSub ←────────────── REST API POST / WS Channel
│
└──────────── Room:{id} / Dm:{id}
Every frontend connected to the same Phoenix backend shares the same rooms, DMs, and realtime message stream regardless of which website it is embedded into.
Base URL: http://localhost:4000/api/v1
Protected endpoints require: Authorization: Bearer <token>
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/auth/register |
{username, password} |
Create account → returns JWT |
POST |
/auth/login |
{username, password} |
Login → returns JWT |
DELETE |
/auth/logout |
— | Logout (client-side token discard) |
GET |
/auth/me |
— | Current user info |
| Method | Path | Description |
|---|---|---|
GET |
/users |
List all users |
GET |
/users/:id |
Get user by ID |
| Method | Path | Body | Description |
|---|---|---|---|
GET |
/rooms |
— | List all group rooms |
POST |
/rooms |
{name, description?} |
Create room (auto-joins as admin) |
GET |
/rooms/:id |
— | Room info + member list |
DELETE |
/rooms/:id |
— | Delete room (creator only) |
POST |
/rooms/:id/join |
— | Join a room |
DELETE |
/rooms/:id/leave |
— | Leave a room |
GET |
/rooms/:id/messages |
?limit=50&before_id=... |
Message history (paginated) |
POST |
/rooms/:id/messages |
{content} |
Send a message |
| Method | Path | Body | Description |
|---|---|---|---|
GET |
/dm |
— | List your DM conversations |
POST |
/dm |
{user_id} |
Start / get DM with a user |
GET |
/dm/:id/messages |
?limit=50 |
DM history |
POST |
/dm/:id/messages |
{content} |
Send a DM |
// Success
{ "data": { ... } }
// Error
{ "error": "message" }
// Validation error
{ "errors": { "field": ["message"] } }Connect to: ws://localhost:4000/chat/socket/websocket?token=<JWT>
// Group room
socket.channel("room:<room_uuid>")
// Direct message conversation
socket.channel("dm:<room_uuid>")| Event | Payload | Description |
|---|---|---|
new_message |
{content: "text"} |
Send a message |
typing |
{} |
Start typing indicator |
stop_typing |
{} |
Stop typing indicator |
| Event | Payload | Description |
|---|---|---|
message_history |
{messages: [...]} |
Last 50 messages on join |
new_message |
{id, content, user, inserted_at, type} |
New message |
typing |
{user_id, username} |
User is typing |
stop_typing |
{user_id, username} |
User stopped typing |
presence_state |
{user_id: {metas: [...]}, ...} |
Current online users |
presence_diff |
{joins, leaves} |
Online users changed |
import { Socket } from "phoenix";
const socket = new Socket("ws://localhost:4000/chat/socket", {
params: { token: myJwtToken }
});
socket.connect();
const channel = socket.channel("room:my-room-id");
channel.on("new_message", (msg) => {
console.log(`${msg.user.username}: ${msg.content}`);
});
channel.on("message_history", ({ messages }) => {
messages.forEach(renderMessage);
});
channel.join()
.receive("ok", () => console.log("joined"))
.receive("error", (err) => console.error("join failed", err));
// Send a message
channel.push("new_message", { content: "Hello!" });// lib/erlex-chat.ts
import { Socket, Channel } from "phoenix";
export class ErlexChat {
private socket: Socket;
private channels = new Map<string, Channel>();
constructor(private apiBase: string, private wsBase: string) {}
async login(username: string, password: string): Promise<string> {
const res = await fetch(`${this.apiBase}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const { data } = await res.json();
return data.token; // JWT
}
async connect(token: string) {
this.socket = new Socket(`${this.wsBase}/chat/socket`, {
params: { token },
});
this.socket.connect();
}
joinRoom(
roomId: string,
onMessage: (msg: any) => void
): Channel {
const ch = this.socket.channel(`room:${roomId}`);
ch.on("new_message", onMessage);
ch.on("message_history", ({ messages }) => messages.forEach(onMessage));
ch.join();
this.channels.set(roomId, ch);
return ch;
}
sendMessage(roomId: string, content: string) {
this.channels.get(roomId)?.push("new_message", { content });
}
async getRooms(token: string) {
const res = await fetch(`${this.apiBase}/api/v1/rooms`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
disconnect() {
this.socket?.disconnect();
}
}
// Usage in a Next.js component
import { ErlexChat } from "@/lib/erlex-chat";
const chat = new ErlexChat("http://localhost:4000", "ws://localhost:4000");
const token = await chat.login("alice", "secret123");
await chat.connect(token);
chat.joinRoom("room-uuid-here", (msg) => {
console.log(msg.user.username, ":", msg.content);
});src/erlex_rate_limiter.erl implements a fixed-window atomic limiter using Erlang ETS tables
(lock-free concurrent reads, atomic counter updates):
| Context | Limit | Window |
|---|---|---|
| Shared message send (API + WS) | 10 msgs | 5 s |
Call from Elixir:
case :erlex_rate_limiter.check_rate("user:#{user_id}", 20, 5_000) do
{:allow, remaining} -> # proceed
{:deny, retry_after_ms} -> # reject
end
src/erlex_msg_stats.erl tracks per-room message counters using atomic ETS updates:
:erlex_msg_stats.record_message(room_id) # -> total count
:erlex_msg_stats.get_room_stats(room_id) # -> integer count
:erlex_msg_stats.get_all_stats() # -> [{{:room, id}, count}]config :erlex, Erlex.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "erlex_dev"config :erlex, Erlex.Auth.Guardian,
secret_key: "CHANGE_THIS_IN_PRODUCTION" # min 32 charsIn production, set
GUARDIAN_SECRET_KEYenv variable and read it inconfig/runtime.exs.
erlex/
├── src/
│ ├── erlex_rate_limiter.erl # Erlang: token-bucket rate limiter (ETS)
│ └── erlex_msg_stats.erl # Erlang: per-room message counters (ETS)
│
├── lib/
│ ├── erlex/
│ │ ├── accounts/
│ │ │ ├── user.ex # User schema + Pbkdf2 password hashing
│ │ │ └── accounts.ex # Accounts context
│ │ ├── auth/
│ │ │ ├── guardian.ex # JWT encode/decode
│ │ │ └── error_handler.ex # 401 JSON errors
│ │ ├── chat/
│ │ │ ├── room.ex # Room schema
│ │ │ ├── room_member.ex # Membership schema
│ │ │ ├── message.ex # Message schema
│ │ │ └── chat.ex # Chat context (rooms, DMs, messages)
│ │ ├── application.ex # OTP Application (starts ETS tables)
│ │ └── repo.ex
│ │
│ └── erlex_web/
│ ├── channels/
│ │ ├── user_socket.ex # JWT-authenticated WebSocket socket
│ │ └── room_channel.ex # Room + DM channel logic
│ ├── controllers/
│ │ ├── api/ # REST API controllers
│ │ ├── error_html.ex
│ │ └── error_json.ex
│ ├── plugs/
│ │ └── api_auth.ex # Bearer JWT auth plug
│ ├── presence.ex # Phoenix.Presence (online tracking)
│ ├── endpoint.ex # Phoenix backend endpoint
│ └── router.ex # API + dev dashboard routes only
│
├── priv/repo/migrations/ # Ecto migrations
├── sample-site/ # Minimal third-party website integration simulation
├── web/ # Next.js frontend client
└── config/
To simulate a random external website adding your chat service, open the demo in:
sample-site/index.html
Use different tenant slugs per website (for example mika-travel-blog and other-website).
Rooms/messages stay isolated per tenant.
All /api/v1/* endpoints allow * origins by default (cors_plug).
For production, set origin: ["https://yourdomain.com"] in the :api pipeline.