Troubleshooting
Find the symptom that matches your problem. Perform its checks in order.
Clients cannot connect at all
Section titled “Clients cannot connect at all”Symptoms: The browser reports a WebSocket error or
net::ERR_CONNECTION_REFUSED. The connection can also close before a Phoenix
message arrives.
Checks:
-
Check that the HTTP server is listening. With Mist, confirm that
mist.startreturnedOk(_). With Ewe, confirm thatewe.startreturnedOk(_). Handle theErrorcase. -
Path mismatch. The Phoenix JS client appends
/websocketto the socket path you pass:new Socket("/socket", ...) // → connects to /socket/websocketYour transport config must match:
server.default_config("/socket/websocket")Raw WebSocket clients (non-Phoenix) connect directly to the path with no suffix.
-
Check
on_connect. Ifwith_on_connectreturnsError(server.ConnectRejected), the server sends HTTP 403 before the upgrade. Check the authentication logic and request headers. -
Check the reverse proxy headers. See Reverse proxy / nginx.
Client connects but joins are never acknowledged
Section titled “Client connects but joins are never acknowledged”Symptoms: The Phoenix JS client stays in the connecting or joining
state. It does not receive phx_reply.
Checks:
-
Check that
updateanswers the join. ReturnAcceptJoinorRejectJoinfor eachJoinevent. The runtime rejects an unanswered join. Check the logs forJoin not acknowledged by update; rejecting. Confirm that a match branch covers the client topic:// "room:" <> _ matches "room:lobby", "room:42", etc.socket.Join("room:" <> _, payload, ref) ->socket.Next(model, [socket.AcceptJoin(ref, option.None)])With
beryl/channel, confirm a handler pattern matches the topic. The layer rejects an unclaimed topic with{"reason": "unmatched topic"}. -
Is
beryl.Socketspassed to the transport? Pass thechannelsvalue fromberyl.child_specorchannel.child_specto the selected transport after its child specification starts:// Mistuse <- mist_transport.upgrade(request, channels, config)// Eweewe_transport.handler(channels, config, http_fallback) -
Check for a
Joincrash. A crash duringJoinrejects the join but keeps the socket open. Check the crash description in the logs and fix the panic. -
Check the topic string.
updateroutes topics with pattern matching. Confirm that the prefix or shape matches the exact client topic."room:" <> _does not match"rooms:lobby". For multiple segments, usetopic.extract_wildcardswithtopic.parse_pattern("document:*:*").
Messages sent from the client are not received
Section titled “Messages sent from the client are not received”Symptoms: update does not receive a Message event. The client does not
receive a reply or push.
Checks:
-
Did the client successfully join?
Messageevents are only delivered after a successfulphx_join. If join was rejected, messages to the topic get anunmatched topicerror reply (when they carry a ref) or are dropped. -
Check rate limits.
with_frame_ratedrops complete frames before decoding.with_message_rate,with_channel_rate, andwith_topic_rateapply after decoding in the runtime. -
Check the event name.
Messagecontains the event string from the client. Confirm that it matches the string inupdate.
Channel handler problems
Section titled “Channel handler problems”child_spec returns a handler error
Section titled “child_spec returns a handler error”InvalidPattern(pattern, reason)means the pattern is not validberyl/topicsyntax.reasonis the nestedTopicError(EmptyTopicorInvalidFormat(detail)). Match both variants explicitly.DuplicatePattern(pattern)means the same pattern string appears twice. Overlapping but different patterns are valid; first match wins.
channel.notify never reaches on_info
Section titled “channel.notify never reaches on_info”The sender belongs to one accepted channel join. Messages for a channel that
closed or joined again are dropped, and notify does not report whether the
channel is still open. Capture context.self from each new join and install
channel.on_info.
A termination action does not compile
Section titled “A termination action does not compile”on_terminate accepts Action(Closing). Use broadcast, broadcast_from,
presence_untrack, or broadcast_presence; active-only pushes, replies, and
presence tracking are rejected by the type checker.
- If
on_terminatepanics, the runtime discards its actions. The runtime still closes the topic and sibling channels. The worker stops.
One channel crash closes more than expected
Section titled “One channel crash closes more than expected”Crash scope depends on the callback. A join panic rejects one join. An
on_message or on_info panic closes one topic. Each topic runs in its own
worker process. See
Crash behavior.
Turn on debug logs
Section titled “Turn on debug logs”beryl uses palabres loggers under the beryl.*
namespaces. Enable debug logs when you diagnose integration problems:
let config = beryl.config(wire.phoenix_codec()) |> beryl.with_logging(beryl.logging_config( level: beryl.DebugLevel, include_payloads: False, ))Debug logs cover frame decode, join delivery, effect outcomes, reply/push send outcomes, disconnects, heartbeat decisions, broadcasts, and rate limiting. Payload and frame previews are omitted by default to reduce accidental sensitive-data exposure. If you need bounded previews locally, enable them explicitly:
let logging = beryl.logging_config(level: beryl.DebugLevel, include_payloads: True) |> beryl.with_payload_preview_bytes(bytes: 100)Broadcasts are not received by clients
Section titled “Broadcasts are not received by clients”Symptoms: Server code calls beryl.broadcast, but connected clients do not
receive the event.
Checks:
-
Check the exact topic string.
beryl.broadcast(sockets, "room:lobby", ...)sends only to sockets on"room:lobby". Wildcard patterns route incoming messages. They do not select broadcast targets. -
Client has not joined the topic. A socket must have successfully completed
phx_joinfor the topic before it receives broadcasts on that topic. -
Single-node vs. multi-node. Without PubSub, broadcasts are local to the node. If your deployment runs multiple BEAM nodes, configure PubSub:
let pubsub_handle = pubsub.start(pubsub.default_config())let config =beryl.config(wire.phoenix_codec())|> beryl.with_pubsub(pubsub_handle) -
broadcast_from excluding the wrong socket.
beryl.broadcast_fromexcludes the socket whose ID you pass. Verify that the socket ID matches the sender.
Presence is stale or incorrect
Section titled “Presence is stale or incorrect”Symptoms: presence.list includes disconnected users. It does not show
recent joins or leaves.
Checks:
-
Untrack on
Closed. Return a presence effect from theClosedarm (orchannel.presence_untrackfromon_terminate):socket.Closed(topic, _reason) ->socket.Next(model, [socket.PresenceUntrack(topic, model.presence_key),])The runtime applies presence effects asynchronously. Do not call the synchronous public presence API inside
initorupdate. -
Cross-node sync. If running multiple nodes, each node must use the same PubSub scope and each presence actor needs a unique replica ID. The CRDT merges state over PubSub; without PubSub, nodes have independent state.
-
on_diffnot broadcasting. If clients rely on receivingpresence_diffevents, confirmon_diffis configured and callsberyl.broadcast_presence_diff. See the Presence guide. -
Healthy clients receive false leaves. Pass the original callback diff to
beryl.broadcast_presence_diff. Encoding it and calling a generic broadcast loses its delivery scope. Replica-view diffs, including failure and recovery decisions, must stay on the observing node; application mutations retain cluster-wide delivery.
Authentication failures
Section titled “Authentication failures”Symptoms: All connections receive HTTP 403, or all joins are rejected.
Checks:
-
on_connectbug. Add logging to youron_connectcallback to confirm tokens are being extracted correctly from headers/query parameters. -
Token validation error. Check that your token validation logic handles expired or malformed tokens gracefully and returns
Error(Nil)rather than panicking. -
Check whether
updaterejects each join. Log theJoinpayload and confirm its shape. The transport has decoded the raw frame, butpayloadis stillDynamic. Run your decoder on it.
Heartbeat disconnects
Section titled “Heartbeat disconnects”Symptoms: Clients disconnect after inactivity. update receives
Closed(topic, HeartbeatTimeout).
Checks:
-
Client heartbeat interval vs. server timeout. The Phoenix JS client sends heartbeats every 30 s by default. The beryl default server timeout is 60 s, which leaves time for a delayed heartbeat reply. If you lower
heartbeat_timeout_ms, keep the client interval at or below half the server timeout. -
Load balancer idle timeout. Some load balancers (AWS ALB, nginx) have their own WebSocket idle timeouts. Set the load balancer timeout to be longer than the client heartbeat interval, or configure load-balancer-level keepalives.
-
Network interruption. Mobile clients behind NAT may lose the WebSocket connection without a TCP close. The Phoenix JS client detects missed heartbeat replies and reconnects automatically.
Broadcasts fail across Erlang nodes
Section titled “Broadcasts fail across Erlang nodes”Symptoms: Broadcasts do not reach other Erlang nodes. Presence state differs between nodes.
Checks:
-
Nodes are clustered. beryl PubSub uses Erlang
pg, which requires Erlang distribution. Confirm nodes can reach each other:nodes().in the Erlang shell should return connected nodes. -
Same pg scope. All nodes must use the same
pgscope name.pubsub.default_config()uses the default scope. If you customized it, make sure all nodes use the same value. -
broadcast_from exclusion is cluster-aware by socket id.
beryl.broadcast_fromexcludes the named socket locally and sends the excluded socket ID across PubSub. On remote nodes, all other sockets subscribed to the topic receive the message; a socket with the matching ID on a remote node is also suppressed.
Rate limits drop valid traffic
Section titled “Rate limits drop valid traffic”Symptoms: Clients receive only some messages. High-rate operations are dropped.
Checks:
-
Check burst values. The
burstparameter sets the token bucket capacity. If burst is too small, a legitimate burst of messages (e.g., on reconnect) exceeds the limit. -
frame_rate vs. message_rate. These are independent.
frame_ratecounts malformed frames, joins, leaves, heartbeats, and messages before decode;message_ratecounts decoded non-join envelopes. -
message_rate vs. channel_rate.
message_rateis per-socket total;channel_rateis per-socket-per-topic. -
No error is sent to the client. Rate-limited traffic is dropped silently (over-rate joins get an error reply). Add application-level feedback if needed.
Reverse proxy / nginx
Section titled “Reverse proxy / nginx”WebSocket upgrades require the Upgrade and Connection headers. Use this
minimal nginx configuration:
location /socket/websocket { proxy_pass http://localhost:4000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400s; # Long timeout for persistent connections}Without HTTP/1.1 and the upgrade headers, nginx uses HTTP/1.0 and the WebSocket
handshake fails. Set proxy_read_timeout above the client heartbeat interval.
This prevents proxy idle disconnects.
All socket messages stop
Section titled “All socket messages stop”Symptoms: All WebSocket operations stop. The runtime does not respond.
Checks:
-
Check the restart budget. The supervised runtime restarts after a crash. After 3 restarts in 5 seconds, the supervisor stops and sends the failure to the process that called
child_spec. Check the logs for the first crash. -
Check the failure scope. beryl catches crashes in
initandupdateand limits them to one socket or topic. A fault elsewhere in a socket actor closes only that socket. If every connection closes, inspect the router and supervisor logs for the first fault. -
Rejoin after a restart. A restarted runtime has no socket state. Transports close their connections when the router dies. The Phoenix JS client reconnects and rejoins after the replacement router starts.
