Building Real-Time Apps with Phoenix + React

Real-time product architecture rarely fails in the demo. It fails after a dropped connection, a duplicate subscription, a cross-tenant authorization mistake, or a deployment that leaves half the clients with stale state.
That is why Phoenix and React can be such a strong combination but only when the boundary between them is designed deliberately.
Phoenix gives the backend lightweight concurrency, supervision, PubSub, and persistent connections through Channels. React gives the product team a mature component ecosystem for complex, highly interactive interfaces. Together, they are a particularly good fit for collaborative SaaS products, live operational dashboards, fintech platforms, marketplaces, and applications with complex client-side workflows.
The difficult part is not opening a WebSocket. It is deciding:
- which system owns each piece of state;
- how HTTP data and real-time events stay consistent;
- what happens when a client misses events;
- where authentication ends and authorization begins;
- and whether the product needs React at all—or would be simpler with LiveView.
This guide focuses on those production decisions.
What changed in the Phoenix + React stack?
The fundamentals are stable, but the tooling has moved forward.
- The current Phoenix documentation is on the 1.8 line. Phoenix 1.8 introduced scopes, which help teams carry user, organization, and permission context through generated domain operations. The current
mix phx.gen.jsongenerator can apply a configured default scope to generated context functions, making access boundaries harder to forget. See the official Phoenix 1.8 release andphx.gen.jsondocumentation. - React 19.2 added
useEffectEvent, which is useful for long-lived external subscriptions because event handlers can read current props and state without forcing the connection effect to restart. See the React 19.2 announcement anduseEffectEventreference. - Phoenix LiveView is now an even stronger alternative for server-driven interfaces. LiveView 1.1 added colocated hooks and JavaScript; LiveView 1.2 added colocated CSS. That reduces the friction of adding small client-side behaviours without introducing a separate React application. See the LiveView 1.2 release.
The result is not that one option has “won.” Teams now have better choices—and a greater need to choose intentionally.
Start with the right integration pattern
There is no universally best Phoenix + React architecture. There are three practical patterns.
| Pattern | Best fit | Main trade-off |
|---|---|---|
| Separate React app + Phoenix API | Independent frontend/backend teams, multiple clients, complex SPA, separate release cycles | More deployment, authentication, CORS, and contract-management work |
| React bundled and served by Phoenix | Early-stage products that want one deployment and same-origin infrastructure | Frontend and backend release cycles remain coupled |
| LiveView application with React at explicit boundaries | Mostly server-driven product with a few highly interactive areas | Requires discipline around which runtime owns each feature |
For a new API-first product, Phoenix can be generated without its HTML and asset layers:
mix phx.new my_app --no-html --no-assets
That is cleaner than generating a full HTML application and manually removing pieces later. Phoenix documents these options in mix phx.new.
However, “API-only” should not become a default ideology. If the same small team owns the full product and most interactions are server-driven, a LiveView-first architecture may ship faster and carry less operational surface area. Choose the topology that fits the product and team, not the one that looks most fashionable on an architecture diagram.

The production model: snapshot, command, event, reconcile
A reliable real-time application should not treat the WebSocket as its database or durable event log. A better model has four parts.
1. Load an authoritative snapshot
React initially fetches a resource through a JSON API—or receives it in the channel join response. That snapshot includes a monotonically increasing version, sequence number, or cursor.
2. Send explicit commands
Mutations can use HTTP or channel.push. Either is valid. What matters is that commands have clear success, error, and timeout behaviour. For retryable operations, include an idempotency key so a reconnect or impatient user cannot create the same action twice.
3. Broadcast versioned domain events
Phoenix publishes events such as order.updated, member.joined, or invoice.paid. Keep the event schema independent from React component structure.
{
"event": "project.updated",
"entity_id": "prj_123",
"version": 42,
"changes": {
"status": "approved"
}
}
Broadcast the smallest payload that lets the client update safely. A compact diff is efficient, but only when it includes enough identity and ordering information to detect duplicates or gaps.
4. Reconcile after interruption
Phoenix’s JavaScript client automatically attempts channel rejoins with exponential backoff after a connection drop or channel crash. It also allows updated join parameters—such as a last_message_id—to be sent on rejoin. But reconnection alone does not recover an event that the browser missed while offline. The application protocol must do that. See the official Channels reliability guidance and JavaScript client documentation.
A robust client therefore sends its last known version when rejoining. The server can return missed events when they are retained, or instruct the client to reload a fresh snapshot.
A WebSocket connection gives you a live transport. It does not give you durable delivery, exactly-once processing, or automatic state repair.
Define state ownership before writing components
Many Phoenix + React problems are really ownership problems. A useful division is:
| State | Owner | Examples |
|---|---|---|
| Durable domain state | Phoenix and the database | Orders, permissions, account balances, workflow status |
| Server-state cache | React query/cache layer | Fetched projects, paginated lists, current user |
| Ephemeral UI state | React component or UI store | Open modal, selected tab, draft filter, animation state |
| Presence | Phoenix Presence, projected in React | Online users, active device, temporary status |
Phoenix should not broadcast isModalOpen, and React should not become the authority for a payment status. The browser may optimistically project a change, but the server remains authoritative and must confirm or reject it.
For server-state caching, tools such as TanStack Query are useful—but their defaults still need to be understood. Cached data is considered stale by default and can refetch on mount, focus, and reconnect. Choose a deliberate staleTime, then use channel events to update or invalidate the relevant query. The TanStack Query defaults guide is worth reading before combining it with real-time updates.
A safe React lifecycle for Phoenix Channels
Phoenix establishes one socket connection and multiplexes multiple channels over it. In React, that usually means:
- create one socket at the application or authenticated-session boundary;
- let a feature hook own the join and leave lifecycle for its channel;
- unsubscribe the exact event handler during cleanup;
- do not disconnect the shared socket when one child component unmounts.
React runs an additional setup-and-cleanup cycle for Effects in development Strict Mode. Treat that as a useful test: if it produces duplicate joins or handlers, the cleanup is incomplete. React explains this behaviour in the useEffect documentation.
Authentication is not authorization
Authenticating the socket proves who opened the connection. It does not prove that the user may join every topic.
Phoenix 1.8 supports a transport-agnostic authToken option. The backend verifies that token in connect/3 and stores the authenticated identity in the socket. Then every channel join/3 must authorize the requested resource against that identity.
For a topic such as organization:42, never trust 42 merely because the client supplied it. Load or verify membership before returning {:ok, socket}. Phoenix’s current token-authentication example demonstrates the connection side; the topic-specific authorization remains application logic.
This is also where Phoenix 1.8 scopes help. Use the same user or organization boundary in HTTP contexts and channel joins so the API and real-time path cannot drift into different authorization rules.
Seven rules that prevent most production incidents
1. Use one subscription per topic
The Phoenix client supports many channel topics over one socket, but only one active subscription per unique topic. A duplicate join closes the old channel. Centralize ownership or use a small subscription registry when several React components need the same topic.
2. Version the protocol, not just the endpoint
REST route versioning does not protect WebSocket consumers from changed event names or payloads. Treat channel events as a public contract. Add fields compatibly, validate payloads at the browser boundary, and define a deprecation window.
TypeScript interfaces alone do not provide runtime or end-to-end safety. Generate types from OpenAPI or GraphQL where appropriate, or validate unknown JSON with a runtime schema before it reaches application state.
3. Make gaps and duplicates harmless
Networks create retries, reconnects, and out-of-order arrival. Include a version, cursor, or event ID. Ignore duplicates, detect gaps, and reload when the client cannot prove continuity.
4. Do not turn every event into a full refetch
Invalidating a query is the safest fallback, but doing it for every high-frequency event defeats much of the benefit of real-time delivery. Apply small deterministic updates locally and refetch only when the event is incomplete, a gap appears, or business logic is too complex to reproduce safely.
5. Keep fan-out work cheap
Avoid database queries or expensive personalization in per-recipient channel callbacks. Phoenix warns that intercepted outgoing events run for every recipient. Precompute shared payloads and keep personalized work bounded.
6. Design the multi-node path explicitly
A one-node demo can hide a broken production topology. Phoenix PubSub’s default adapter uses Distributed Elixir for cross-node delivery; an official Redis adapter is available when direct node communication is not practical. Confirm that messages reach clients connected to different instances, and test rolling deployments. See the Phoenix.PubSub adapter documentation.
7. Observe the real-time path as a product capability
Measure connection count, join failures, reconnect rate, message lag, payload size, events per topic, and client reconciliation frequency. Phoenix applications include Telemetry infrastructure, which can be extended with domain-specific metrics. See the official Phoenix Telemetry guide.
Load tests should model more than concurrent sockets. Include join bursts, fan-out, slow consumers, token expiry, instance termination, and the reconnect storm that follows a deployment or regional network issue.
Phoenix Channels, LiveView, or React?
The decision is mainly about where interaction state should live.
| Choose | When it is usually the better fit |
|---|---|
| Phoenix + React | Complex client-side workflows, drag-and-drop, canvas or editor experiences, offline/local-first behaviour, extensive React component investment, or several backend clients |
| Phoenix LiveView | Server-authoritative CRUD, internal tools, operational dashboards, forms, workflow apps, and teams that want one language and one deployment |
| Hybrid | The product is mostly server-driven but contains a few rich client-side surfaces that clearly justify React |
LiveView keeps state on the server and sends rendered diffs to the browser. It also starts with a regular HTTP response, which gives public pages a simpler rendering and indexing path than a client-only SPA. Phoenix describes the model in its LiveView guide.
React remains the stronger option when the browser must continue doing meaningful work during poor connectivity or when interaction state is too rich, frequent, or local to round-trip to the server.
For a hybrid product, use route-level or feature-level boundaries. Avoid letting React and LiveView both believe they own the same DOM subtree or durable state.
Common failure modes
| Symptom | Likely cause | Better approach |
|---|---|---|
| Every event fires twice after navigation | Listener or channel was not cleaned up | Store the channel.on ref, call channel.off, then leave the channel |
| Unmounting one feature breaks all live updates | A child disconnected the shared socket | Leave only the feature channel; disconnect the socket at the session boundary |
| UI looks connected but is stale | Rejoin succeeded without replay or snapshot repair | Rejoin with a cursor/version and reconcile missed state |
| A user can subscribe to another tenant | Topic ID was trusted without a permission check | Authorize every join/3 using the authenticated socket identity |
| Real-time works on one server but not across instances | PubSub clustering or adapter is misconfigured | Test cross-node fan-out in a production-like topology |
| React continually reconnects | Effect dependencies change on each render | Stabilize connection inputs and use useEffectEvent for non-reactive handler logic |
| API and frontend drift after releases | Contracts are manually duplicated | Generate or validate contracts and run consumer-facing integration tests |
A CTO checklist before choosing this stack
Phoenix + React is a good choice when most of the following are true:
- real-time collaboration or live operational data is central to the product;
- the UI requires more client-side complexity than LiveView would comfortably own;
- the team already has strong React or TypeScript capability;
- separate frontend and backend release cycles create real organizational value;
- the team is prepared to own an API and event contract;
- reconnect, replay, authorization, and observability are part of the architecture - not backlog items for later.
If those conditions are not true, Phoenix LiveView may deliver the same product with fewer moving parts.
Key takeaway
Phoenix + React is not powerful because it combines two popular tools. It is powerful because each side can own the work it is best at:
- Phoenix owns domain truth, concurrency, authorization, fan-out, and recovery.
- React owns complex browser interaction and ephemeral interface state.
- A versioned contract connects them.
When that boundary is explicit, the stack can support highly interactive products without turning the frontend and backend into one distributed state machine that nobody fully understands.
When the boundary is vague, the WebSocket is usually where the symptoms appear - but architecture is where the problem began.
How Elixirator approaches Phoenix + React projects
At Elixirator, we work with Phoenix / LiveView, React, and TypeScript across SaaS, fintech, ecommerce, and other real-time products. Our focus is not simply connecting a React frontend to a Phoenix channel. We help teams define everything they need to remain system reliable as usage grows.
Whether you need an architecture review, help stabilizing an existing real-time product, or senior engineers who can work across Phoenix and React, talk to the Elixirator team.
FAQ
Is Phoenix with React a good architecture for a startup?
Yes—when the product needs complex client-side interaction and real-time backend capabilities. For a small team building mainly forms, dashboards, and server-driven workflows, LiveView may be faster to ship and simpler to operate.
Should Phoenix Channels replace the REST or GraphQL API?
Usually no. A common production design uses an HTTP API for snapshots and many commands, then Channels for live events and presence. Channel pushes are appropriate when bidirectional low-latency commands add value, but they still need explicit acknowledgements, timeouts, idempotency, and contract tests.
Do Phoenix Channels guarantee that React receives every event?
No. The client automatically reconnects and rejoins, but events can be missed while it is offline. Add versions or cursors and provide a replay or snapshot-reload path.
How should Phoenix Channels be connected to React state?
Use a shared socket, feature-owned channel subscriptions, exact listener cleanup, and a server-state cache such as TanStack Query. Apply safe versioned events directly and invalidate the relevant query when continuity cannot be guaranteed.
When should a team choose LiveView instead of React?
Choose LiveView when the server should own most interaction state and the product does not depend heavily on offline behaviour, complex editors, canvas interactions, or an existing React ecosystem. LiveView 1.1 and 1.2 also make small JavaScript and CSS enhancements easier to colocate with components.

