Why Phoenix LiveView renders twice on first load

almirsarajcic2 pts0 comments

Stop mounting your LiveView twice

We can't find the internet

Attempting to reconnect

Something went wrong!

Hang in there while we get back on track

Notifications

No new notifications at the moment

Stop mounting your LiveView twice

almirsarajcic

15 hours ago

0 comments

Copy link

Every LiveView mounts twice on first page load — once for the static HTTP “dead render,” then again when the WebSocket connects. If mount/3 loads data unconditionally, you run that query, hit that cache, and rebuild those assigns two times for a single visit. Guard the work you don’t need for first paint with connected?/1:

# ❌ Runs your query TWICE on every first page load<br>def mount(_params, _session, socket) do<br>{:ok, assign(socket, :posts, Blog.list_posts())}<br>end

# ✅ Skip the dead-render query; load once, when connected<br>def mount(_params, _session, socket) do<br>posts = if connected?(socket), do: Blog.list_posts(), else: []<br>{:ok, assign(socket, :posts, posts)}<br>end

That’s the conventional fix — the one you’ll find in every LiveView thread. connected?(socket) is false during the dead render and true once the socket is live, so the guarded branch runs exactly once. Subscriptions, timers, presence tracking, and expensive loads all get parked behind it.

Here’s the catch, though: that guard is a band-aid, not a cure. It doesn’t even stop the double render — mount/3, render/1, and your on_mount hooks all still run twice; it just trades your query for an empty first paint — quietly taking your SEO, your link previews, and your no-JavaScript fallback down with it. By the end of this post you’ll have a sharper default — load your data plainly, move connection-only work into an on_connect/1 callback, and let the dead render’s work be reused on connect instead of repeated. But to get there you first have to understand why LiveView renders twice at all, what connected?/1 actually costs you, and what the Phoenix team is building to erase the double mount for good.

Why a LiveView renders twice

When a browser first requests a LiveView route, there is no WebSocket yet — just a plain HTTP request. Phoenix can’t stream diffs over a socket that doesn’t exist, so it does the pragmatic thing: it runs your LiveView once as an ordinary request and returns static HTML. This is the dead render (a.k.a. the disconnected render). Then the JavaScript client boots, opens a WebSocket, and Phoenix runs your LiveView again — this time as a long-lived stateful process that pushes diffs. This is the connected render .

# FIRST PAGE LOAD = TWO mounts, not one<br># 1. HTTP request -> DEAD render (static HTML: fast first paint, SEO)<br># mount/3 connected?(socket) == false<br># handle_params/3<br># render/1 -> full HTML sent over the wire<br># 2. WebSocket join -> CONNECTED render (the stateful process)<br># mount/3 connected?(socket) == true<br># handle_params/3<br># render/1 -> FULL render sent (not a diff), page becomes "live"

So mount/3, handle_params/3, and render/1 all run twice on first load. The dead render earns its keep for two real reasons:

First paint and SEO. The user (and Googlebot) get meaningful HTML immediately, before any JavaScript executes.

Progressive enhancement. Even if the WebSocket never connects, the page still shows something useful.

The connected render exists because that is where LiveView actually lives: a process that holds state, receives messages, and pushes minimal diffs. Two different jobs, two passes through your mount/3.

What connected?/1 actually costs you

Here’s what the one-liner tip never says out loud: the guard doesn’t remove the double render — it just makes the dead render useless for the one job it exists to do. mount/3, render/1, and every on_mount hook still run twice; you’ve only swapped your expensive query for an empty branch. The structural duplication — auth, current_user, layout, the per-request work in your hooks, which is often where the real cost lives — is completely untouched. And in exchange for cheapening that one branch, you take on four real costs:

An empty first paint. No data on the dead render means a skeleton, a spinner, or a blank box until the WebSocket finishes its round-trip. That pushes Largest Contentful Paint behind the socket, and the content popping in afterward shifts the layout unless your placeholder reserves the final content’s exact space — two Core Web Vitals that Google scores you on.

Broken SEO and link previews. This is the big one, and it is not just “Googlebot.” Many crawlers don’t reliably run JavaScript — Bing’s rendering is limited and inconsistent, and every social unfurler (Slack, X, Facebook, LinkedIn, iMessage) that fetches your page to build an Open Graph card runs none at all. They get the dead render, and the dead render is empty. Even Googlebot, which does render JS, defers it to a later pass and makes no promise it will open and await your LiveView socket — WebSocket-delivered content is not server-rendered HTML. And your social tags make this concrete: og:image,...

render liveview first socket dead connected

Related Articles