Lesson 40: The Final Project and Where to Go Next
The whole chat server on one page, one message traced through every process, a production checklist, and the parts worth building next
Lesson 00 promised a WebSocket chat server in pure Erlang, no dependencies, able to hold thousands of connections. That server exists. It serves HTTP and WebSocket on one port, authenticates users, keeps rooms with history in Mnesia, spans a cluster, and ships as a release with tests and a JSON API.
The Whole System on One Page
chat_server_app:start/2 runs chat_store:init/0 to create the Mnesia schema and tables, then starts the top supervisor. Children start top to bottom, stop in reverse, and are permanent workers except where marked.
chat_server_sup one_for_one, intensity 5, period 10 chat_stats chat_events chat_events_guard chat_registry chat_auth pg_chat pg scope chat, lesson 31 chat_room_sup simple_one_for_one chat_room temporary, one per room chat_rooms chat_conn_sup simple_one_for_one chat_connection temporary, one per client chat_listener chat_metrics chat_partition chat_clusterThe two simple_one_for_one supervisors set shutdown => infinity, and their children are temporary: a restarted connection would have no socket. Order is dependency order. pg_chat precedes the rooms because chat_room:init/1 joins the scope, chat_room_sup precedes chat_rooms because the manager terminates leftover rooms at startup, and the listener follows everything a new client touches.
Every module, with the lessons that shaped it.
chat_server_app,chat_server_sup(00, 14, 28). Start the store, then the treechat_stats(12, 33). Counters, pluscode_change/3and a versioned statechat_registry(13, 20, 27). Online users.broadcast/1andlookup/1read ETS in the callerchat_auth(21, 28, 32).register/2,login/2,verify/1,logout/1, with users and sessions in Mnesiachat_http,chat_ws(16 to 18, 34, 39). Pure parsing.parse_request/1,body/2,decode_frame/2,encode_frame/1chat_listener,chat_conn_sup(19, 25, 35, 36). Listen socket and acceptors, one child per socketchat_connection(19 to 22, 26, 27, 34, 39). One gen_server per client. Buffer, phase, protocol, limitschat_transport,chat_rate(34).gen_tcpandsslbehind one API. Token bucketchat_room_sup,chat_rooms(22, 39).start_room/1,ensure_room/1,list_rooms/0chat_room(22, 28, 31, 38, 39). Members, history, Mnesia writes,pgforwarding, event notificationchat_store(28, 32). Mnesia tableschat_message,chat_user,chat_session, replicatedchat_metrics,chat_bench,chat_load(26, 27, 35).snapshot/0for/health, timing and loadchat_eventsand its handlers (38, 39). Manager plus log, stats, moderation and webhook handlerschat_api(39).segments/1and the JSON route tablechat_cluster,chat_discovery,chat_partition(30, 32). Connect nodes, find peers, watch Mnesia
test/ holds chat_ws_tests, chat_http_tests, chat_registry_tests, chat_api_tests and chat_SUITE with its ws_client helper. applications in chat_server.app.src ends at [kernel, stdlib, crypto, mnesia, ssl, inets]. my_server, kv_store, echo_server, relay, greeter and chat_session only ever taught.
One Message, End to End
Alice types hi in the erlang room and her browser runs ws.send(JSON.stringify(msg)).
- The browser masks the frame with a random 4-byte key and writes the bytes, which cross the network as TCP segments.
- Her socket is in
{active, once}mode and owned by herchat_connection, so the bytes arrive as one{tcp, Socket, Data}message (or{ssl, ...}after lesson 34) inhandle_info/2, which appends them toState#state.bufferand callsprocess/1. process/1callschat_ws:decode_frame(Buffer, MaxFrame). Bit syntax reads the FIN bit, opcode, mask bit and the 7, 16 or 64 bit length, andmask/2XORs the payload with the key. A partial frame returnsmore, soactivate/1re-arms the socket. A complete one returns{ok, {text, Payload}, Rest}, andReststays in the buffer, because one segment can hold several frames.handle_frame({text, Text}, State)runsjson:decode(Text)insidetry ... of, giving a map with binary keys.handle_message/2picks the clause matching#{<<"type">> := <<"message">>, <<"room">> := Room, <<"text">> := Text}. The gate clause above it answersnot_authenticatedwhileusernameisundefined.chat_rate:take/1spends a token, and text over 2000 bytes is refused.chat_room:send_message(Room, Name, Text)resolves the name throughchat_rooms:ensure_room(Room), then doesgen_server:call(Pid, {message, Name, Text}).- The roomβs
handle_call/3clause checksmaps:is_key(Sender, Members)and answers{error, not_member}to outsiders. It buildsEvent = #{type => message, room => Name, from => Sender, text => Text}, persists it withchat_store:save_message/3, callschat_events:notify/1, and trims history withlists:sublist([Event | History], 50). - Delivery is local first.
notify/2runsmaps:foreachover the members, sending{chat_event, Event}to each pid. Thenpg:get_members(chat, {room, Name})minuspg:get_local_members/2gives the same room on every other node, and each gets{remote_message, Event}. Those rooms deliver locally and stop, because forwarding again would send the message round the cluster forever. gen_event:notify/2walks the handlers in turn.chat_stats_handlerbumps a counter,chat_log_handlerwrites aloggerreport,chat_moderation_handlerchecks the words,chat_webhook_handlerposts out.- Every memberβs
chat_connectiongets{chat_event, Event}inhandle_info/2.send_event/2callsjson:encode(Event), which turns atom keys into JSON strings and returns iodata.send_frame/2callschat_ws:encode_frame({text, Json})for an unmasked server frame, andchat_transport:send/2writes it. - Each browser fires
ws.onmessage, andhandle/1appends a line withtextContent.
Aliceβs own connection takes step 10 like everyone else, so every client sees one order.
What Makes It Fault Tolerant
Processes share no memory, so a crash destroys only what one process holds. A dying chat_connection takes one socket with it, and chat_conn_sup logs it and lets it go. Monitors in chat_registry and in every chat_room turn that exit into a 'DOWN' message, and the user leaves the member lists with no cleanup code anywhere. A crashed room loses its members and history, but chat_rooms sees the 'DOWN', the next ensure_room/1 starts a fresh one, and chat_store:recent_messages/2 reloads it.
Links go the other way. chat_listener links its acceptor and does not trap exits, so neither outlives the other. When a node dies, pg drops its rooms from every group and clients reconnect with the token from lesson 21. None of this needed a try. It needed somebody watching.
Production Checklist
- TLS, frame and message limits, rate limiting, lesson 34.
ws://leaks tokens - Accounts, token expiry, a gate clause every message passes, lesson 21
- A release with
config/sys.configandvm.args, lesson 25. No hard-coded ports loggerreports,chat_metrics:snapshot/0, alerts on queue length, process count, memory, lesson 26- A
/healthendpoint and shutdown that drains connections, lesson 36 rebar3 eunit,rebar3 ctanddialyzerin CI, lessons 23, 24, 36- Mnesia directory backups and a replica on a second node, lessons 28, 32
- Cookie hygiene, lesson 29. The cookie is full access to the VM
- VM and OS limits raised before the load arrives, lesson 35
What Is Deliberately Missing
- Delivery receipts. Give each
chat_roomevent an id, add an ack type tochat_connection, track unacked ids. - Direct messages. Lesson 20 left this as an exercise.
global:whereis_name({user, Name})from lesson 31 finds a connection on any node. - Presence and typing. High volume, worthless a second later. Route them through
chat_eventsandpg, never into Mnesia. - File uploads.
chat_http:body/2reads a bounded body. Megabytes do not belong in a process state. - Moderation. The lesson 38 handler flags words. Bans and an audit trail need
chat_storetables. - A real database. A million messages want Postgres behind the
chat_storeAPI, changing nothing else. - Scaling past a full mesh. Distributed Erlang connects every node to every other. Past roughly 100 nodes, use hidden nodes or a broker.
- An admin UI.
chat_apialready returns rooms and messages.
Where to Go Next
Cowboy and Ranch. Ranch is the acceptor pool of lessons 19 and 35, written by people who profiled it. Cowboy is chat_http and chat_ws with the parts we skipped, including fragmentation, HTTP/2 and compression. Read the source and you will recognise it.
Elixir and Phoenix. Same VM, same processes and supervisors. Channels are chat_room and pg, and LiveView keeps a stateful process per browser tab, obvious once you know OTP and baffling before. Nerves puts these trees on embedded hardware, where a restart beats a reboot.
RabbitMQ and Riak have public source worth reading. RabbitMQ shows flow control at scale, Riak shows consistent hashing and conflict resolution, where lesson 32 stopped.
PropEr generates inputs instead of listing them. Point it at chat_ws:encode_frame/1 composed with decode_frame/1 and it finds what lesson 23 missed. Dialyzer is the other half. Add -spec lines and run it until quiet, and it reports unreachable clauses and impossible return values.
Books and docs. Learn You Some Erlang for Great Good! is free online, and Designing for Scalability with Erlang/OTP covers releases and clustering properly. The OTP Design Principles guide at erlang.org/doc answers most questions faster than a search engine. All of these are pointers, not benchmarked endorsements.
Exercises
Exercise 1: Typing indicators across the cluster
Add {"type":"typing","room":"erlang"}. The room forwards a typing event to its other local members and, through pg, to peer rooms, leaving history alone.
% Expected, and nothing in recent_messages/2:% #{type => typing, room => <<"erlang">>, from => <<"alice">>}Exercise 2: Direct messages with offline delivery
Add {"type":"direct","to":"bob","text":"psst"}. Deliver it live when global:whereis_name({user, Name}) finds a connection, otherwise store it in a new Mnesia table and deliver it on the next login.
% Expected: bob logs in later and receives itExercise 3: Swap the store backend
Keep the chat_store API and write a second module that talks to a database over TCP, picked by an app env key. Nothing else may change.
% Expected: rebar3 ct passes with either backend configuredKey Takeaways
- The tree is the architecture. Start order is dependency order, a childβs restart type says what happens when it fails, and one process covers each thing that fails alone
- Messages are the only coupling.
{chat_event, Event}and{remote_message, Event}are the seams, so a room can move nodes unnoticed - Monitors clean up state, links share fate. Neither needs the failing process to cooperate
- Pure functions at the edges.
chat_wsandchat_httphold no state, so they were easiest to test - Durable data lives outside processes. What a process holds dies with it, by design
- Everything here is stdlib.
gen_server,supervisor,gen_event,ets,mnesia,pg,ssl,logger,jsonandcrypto
Interactive Koans
Koans - Test Your Understanding
Fill in the blanks and press Enter or click Run to test your knowledge!
Which tag does chat_room use when it sends an event to a member?
π‘ Hint
π Explanation
The spawned process waits for a different pattern. What does this receive return?
π‘ Hint
π Explanation
Which strategy lets one child crash while the others keep running?
π‘ Hint
π Explanation
What exit reason does the DOWN message carry?
π‘ Hint
π Explanation
Which opcode does chat_ws read out of this text frame header?
π‘ Hint
π Explanation
Which name completes the sorted member list?
π‘ Hint
π Explanation
Finished this lesson?
Mark it as complete to track your progress
This open source tutorial is brought to you by East Branch Software - we build reliable software systems.
Found an issue? Edit this page on GitHub or open an issue