Building Real-Time Apps with Phoenix + React

Building Real-Time Apps with Phoenix + React article image cover

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.json generator can apply a configured default scope to generated context functions, making access boundaries harder to forget. See the official Phoenix 1.8 release and phx.gen.json documentation.
  • 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 and useEffectEvent reference.
  • 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.

PatternBest fitMain trade-off
Separate React app + Phoenix APIIndependent frontend/backend teams, multiple clients, complex SPA, separate release cyclesMore deployment, authentication, CORS, and contract-management work
React bundled and served by PhoenixEarly-stage products that want one deployment and same-origin infrastructureFrontend and backend release cycles remain coupled
LiveView application with React at explicit boundariesMostly server-driven product with a few highly interactive areasRequires 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.

Phoenix and React real-time application architecture

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:

StateOwnerExamples
Durable domain statePhoenix and the databaseOrders, permissions, account balances, workflow status
Server-state cacheReact query/cache layerFetched projects, paginated lists, current user
Ephemeral UI stateReact component or UI storeOpen modal, selected tab, draft filter, animation state
PresencePhoenix Presence, projected in ReactOnline 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.

ChooseWhen it is usually the better fit
Phoenix + ReactComplex client-side workflows, drag-and-drop, canvas or editor experiences, offline/local-first behaviour, extensive React component investment, or several backend clients
Phoenix LiveViewServer-authoritative CRUD, internal tools, operational dashboards, forms, workflow apps, and teams that want one language and one deployment
HybridThe 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

SymptomLikely causeBetter approach
Every event fires twice after navigationListener or channel was not cleaned upStore the channel.on ref, call channel.off, then leave the channel
Unmounting one feature breaks all live updatesA child disconnected the shared socketLeave only the feature channel; disconnect the socket at the session boundary
UI looks connected but is staleRejoin succeeded without replay or snapshot repairRejoin with a cursor/version and reconcile missed state
A user can subscribe to another tenantTopic ID was trusted without a permission checkAuthorize every join/3 using the authenticated socket identity
Real-time works on one server but not across instancesPubSub clustering or adapter is misconfiguredTest cross-node fan-out in a production-like topology
React continually reconnectsEffect dependencies change on each renderStabilize connection inputs and use useEffectEvent for non-reactive handler logic
API and frontend drift after releasesContracts are manually duplicatedGenerate 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.

elixir
elixir development
phoenix
react
real-time systems

Ready to Build with Elixirator?

Prefer a quick call?
Photo of Alex Danyliak, Client Partner at Elixirator

Alex Danyliak

Client Partner at Elixirator

“Whether it’s a one-off consulting gig or a full dedicated team, let’s chat about how Elixirator can help you build something reliable, performant, and future-proof.”

Ready to Build with Elixirator?

Prefer a quick call?
Photo of Alex Danyliak, Client Partner at Elixirator

Alex Danyliak

Client Partner at Elixirator

“Whether it’s a one-off consulting gig or a full dedicated team, let’s chat about how Elixirator can help you build something reliable, performant, and future-proof.”