Building Search-as-You-Type in Phoenix Without Writing JavaScript
Phoenix LiveView lets you build search-as-you-type and live validation in pure Elixir. A worked example, plus an honest look at memory, reconnects, and when a SPA still wins.
09 Mar 2026, 16:35 UTC

You need a search box that filters results as the user types. The traditional answer is a JavaScript frontend hitting a JSON endpoint, which means a second codebase, a build pipeline, and state duplicated on both sides of the wire. Phoenix LiveView offers a different trade: keep all the logic in Elixir, render HTML on the server, and let the framework push only the changed markup to the browser over a persistent WebSocket. For a large class of interactive features — live search, inline validation, dashboards — that trade is very favorable.
This post walks through how LiveView actually works, builds a working live search page, and then gets honest about where the model breaks down. Examples assume Phoenix 1.7+ with LiveView 0.18 or later; function names and defaults have shifted across releases, so check the changelog for your version.
The LiveView lifecycle in three callbacks
A LiveView is just an Elixir module with three key callbacks:
mount/3runs when the page loads (and again when the socket connects). It sets up initial state in the socket assigns — a map of values your template can read.handle_event/3receives events from the browser. Bindings likephx-change,phx-submit, andphx-clickin your markup declare which events fire and what payload they carry.render/1(or a colocated HEEx template) turns the assigns into HTML.
The first request is a normal HTTP response, so the page works for crawlers and shows content immediately. Then the browser upgrades to a WebSocket, and from that point every event round-trips through the server. LiveView diffs the rendered output and sends only the parts that changed — assigns that didn't change are not re-sent. You can confirm this yourself: open browser dev tools, watch the WebSocket frames, and type into a LiveView form. The frames after the initial render are small diff payloads, not full pages.
Worked example: a live search page
Here's a minimal but idiomatic search LiveView. The input fires a search event on every keystroke; the server filters and re-renders the list.
defmodule MyAppWeb.CatalogSearchLive do
use MyAppWeb, :live_view
alias MyApp.Catalog
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:query, "")
|> assign(:products, Catalog.list_products())}
end
def handle_event("search", %{"query" => query}, socket) do
products = Catalog.search_products(query)
{:noreply, assign(socket, query: query, products: products)}
end
def render(assigns) do
~H"""
<div>
<form phx-change="search">
<input type="text" name="query" value={@query}
placeholder="Search products..." autocomplete="off" />
</form>
<ul>
<li :for={product <- @products}>
{product.name} — {product.price}
</li>
</ul>
</div>
"""
end
endWire it up in the router with live "/search", CatalogSearchLive inside a live_session block. That's the whole feature — no fetch calls, no JSON serialization, no client-side state management.
A few things worth noticing:
Catalog.search_products/1is a plain Ecto query (e.g.,where: ilike(p.name, ^"%#{query}%")). Because the logic is server-side, you query the database directly instead of building an API layer.- The same
phx-changemechanism powers live form validation: bind a changeset to the form and validation errors appear as the user types, again with no custom JavaScript. - If results should update when other users change the data, broadcast on
Phoenix.PubSuband handle the message inhandle_info/2— real-time sync across tabs comes almost free.
To verify behavior, run the app with mix phx.server, open the page, and type in the box. You should see one search event per keystroke in the server log and small diff frames in the browser's WebSocket inspector. If you see full HTML in every frame, something is defeating change tracking (a common cause is rebuilding a derived assign on every render).
When the list gets big: streams
The example above keeps the full product list in socket memory, which is fine for dozens of rows and careless for thousands. LiveView's stream/4 (available in recent 0.18+ releases) handles large collections: items are inserted, updated, or deleted in the DOM without holding the whole collection in the socket's assigns. If your search can return large result sets, reach for streams early rather than after a memory incident — and cap results with a LIMIT in the Ecto query regardless.
The honest trade-offs
LiveView is not a free lunch, and the costs are structural rather than incidental:
- Persistent connections. Every connected client holds a WebSocket and a server-side process with its own state. At high concurrency this is a real memory budget, unlike stateless HTTP where idle users cost nothing. Load-test with realistic connection counts before assuming it scales like your REST endpoints.
- Reconnect handling. Deploys, network drops, and laptop sleep all sever the socket. LiveView reconnects automatically and re-mounts, but transient form state that lives only in the socket can be lost. Design for it: keep important drafts in the database or the session.
- Latency sensitivity. Every interaction is a server round-trip. On a good connection this is imperceptible; for users far from your server or on flaky mobile networks, keystroke-level interactivity can feel sluggish. Highly latency-sensitive or offline-first UIs (a drawing app, a field tool with no signal) still favor a SPA or native client.
- Network policy. Some corporate proxies and locked-down environments block WebSockets. If that's your audience, verify connectivity before committing.
A practical decision rule: if the feature is fundamentally about reading and writing server-side data — search, forms, admin tools, dashboards, collaborative views — LiveView is usually the simpler and more maintainable choice. If the feature must work offline or demands sub-50ms interaction latency, keep a client-side app for that slice.
Try it this week
The fastest way to build intuition is to generate a fresh app (mix phx.new demo includes LiveView by default), implement the search example above against a small Ecto table, and watch the WebSocket frames as you type. Then deploy it somewhere and kill the server mid-session to see the reconnect behavior your users will experience. That hour of poking will tell you more about whether LiveView fits your project than any benchmark post — including this one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.