Your first real-time feature: a shared cursor
This document is a code-along tutorial. You'll start with a fresh Phoenix application and finish with a working shared-cursor feature, where you can open the same page in two browsers and watch each other's mouse cursors move in real time. Coding the whole thing will take you about 30 minutes.
If you've never built a Phoenix Channel before, this is the right place to start. If you have, you can skim through the tutorial; however, its value lies in showing the full lifecycle (server, client, transport, broadcast) end to end, not in any one section.
What you'll build
In this shared-cursor application, you'll open two browser windows and point them to the same URL (typically http://localhost:4000 if you're working in development mode on your local machine). When you move your mouse in one browser window, a labeled cursor will appear in the other, following your movements in real time. Open a third window, and your labeled cursor will appear and move around in both of the remote browsers.
Under the hood, this sample web application will do the following:
- The browser sends its cursor position to the server over a WebSocket every 50 ms (debounced).
- The server broadcasts each position update to all other subscribers to the same topic.
- The browser draws cursors for every other user it hears from.
Before you start
Before you can follow along in this tutorial, you'll need to have the following installed on your local machine:
- Erlang 29.0 and Elixir 1.19.5 or later (we recommend using the mise version manager to install Erlang and Elixir).
-
Phoenix 1.8.7 installed (install using the
mix archive.install hex phx_newcommand in your terminal after you've installed Erlang and Elixir). - A text editor and a terminal.
This tutorial doesn't use a database, LiveView, or any client frameworks. You'll only need to use vanilla JavaScript, Phoenix, and Channels.
Step 1: Generate a Phoenix app
Create a new project:
mix phx.new cursor_demo --no-ecto --no-mailer --no-dashboard
cd cursor_demo
The flags skip the database, mailer, and dashboard, none of which are necessary for this exercise. After running the commands above, you will have a Phoenix app with HTTP routing, an asset pipeline, and channel scaffolding.
Verify it runs:
iex -S mix phx.server
Open http://localhost:4000. You should see the Phoenix
welcome page. Stop the server by typing Ctrl+C twice. You should now be at your
shell's prompt.
Note: You can run this application by typing mix phx.server in the root
directory of the project, but adding iex -S to the front of this command
allows you to interact with the running server using a REPL called IEx, which
is useful for debugging and testing your code.
Step 2: Define the Channel
In older versions of Phoenix, the mix phx.new <app_name> command would
automatically create a UserSocket for you at
lib/cursor_demo_web/channels/user_socket.ex; however, as of Phoenix 1.8, mix phx.new <app_name> no longer generates the UserSocket module. The Phoenix
team made this decision to keep the generated app lean for the common cases
where you don't need raw channels. Instead, Phoenix 1.8 provides a generator
utility to help you generate basic sockets that you can tweak to suit your
needs. Let's use that generator to build a UserSocket.
mix phx.gen.socket User
If you see this output, you've successfully generated the UserSocket.
* creating lib/cursor_demo_web/channels/user_socket.ex
* creating assets/js/user_socket.js
Add the socket handler to your `lib/cursor_demo_web/endpoint.ex`, for example:
socket "/socket", CursorDemoWeb.UserSocket,
websocket: true,
longpoll: false
For the front-end integration, you need to import the `user_socket.js` in your `assets/js/app.js` file:
import "./user_socket.js"
Now edit your UserSocket file at lib/cursor_demo_web/channels/user_socket.ex
and uncomment the channel line (it will be on line 15 if you're using Phoenix
1.8.7). After uncommenting this line, your user_socket.ex file should look
like this (ignoring all the comments in the file):
defmodule CursorDemoWeb.UserSocket do
use Phoenix.Socket
channel "room:*", CursorDemoWeb.RoomChannel
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
The pattern room:* matches any topic that starts with room,
so room:lobby, room:42, and room:demo all route to the same channel
module. The * segment lets the client choose the room.
Now create the channel module at lib/cursor_demo_web/channels/room_channel.ex:
defmodule CursorDemoWeb.RoomChannel do
use CursorDemoWeb, :channel
@impl true
def join("room:" <> _room_id, _params, socket) do
# Assign a per-connection identifier so other clients can label cursors.
socket = assign(socket, :user_id, generate_user_id())
{:ok, %{user_id: socket.assigns.user_id}, socket}
end
@impl true
def handle_in("cursor_move", %{"x" => x, "y" => y}, socket) do
broadcast_from!(socket, "cursor_move", %{
user_id: socket.assigns.user_id,
x: x,
y: y
})
{:noreply, socket}
end
defp generate_user_id do
:crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
end
end
Three things are happening here:
-
join/3: runs when a client connects to a topic. We assign a unique:user_idto thesocketand return it to the client in the join reply. -
handle_in("cursor_move", ...): runs when a client sends a "cursor_move" event. We broadcast the position to every other subscriber via broadcast_from!/3. This code skips the sender, which is what we want, since there's no need to echo your own cursor back to yourself. -
generate_user_id/0: produces a short random hex string so each connection has an identity.
Step 3: Add the page
Replace the contents of lib/cursor_demo_web/controllers/page_html/home.html.heex with:
<div id="cursor-canvas" class="fixed inset-0 cursor-crosshair">
<p class="p-4 text-sm text-gray-500">
Move your mouse. Open another window to the same URL to see others.
</p>
</div>
That's all the markup we need. JavaScript will create the cursors itself at runtime.
Step 4: Wire up the JavaScript client
Now, we must wire up the JavaScript client code to handle our Phoenix Channel.
First, open assets/js/app.js.
Phoenix's mix phx.gen socket generator, which we ran in an earlier step, added a commented-out Socket import to this file; you'll need to uncomment it.
import "./user_socket.js"
The top of the file should now look like this:
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
...
Next, open lib/cursor_demo_web/endpoint.ex.
Right below the LiveView socket definition that looks like this:
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
Add the following socket definition for our UserSocket:
socket "/socket", CursorDemoWeb.UserSocket,
websocket: true,
longpoll: false
The top few lines of the file should now look like this:
defmodule CursorDemoWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :cursor_demo
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_cursor_demo_key",
signing_salt: "T0zS/Qu7",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
socket "/socket", CursorDemoWeb.UserSocket,
websocket: true,
longpoll: false
...
Now, open assets/js/user_socket.js.
This file contains all the client-side code for tracking the cursor's position. Edit the file so it looks like the code below:
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "assets/js/app.js".
// Bring in Phoenix channels client library:
import {Socket} from "phoenix"
// And connect to the path in "lib/cursor_demo_web/endpoint.ex". We pass the
// token for authentication.
//
// Read the [`Using Token Authentication`](https://hexdocs.pm/phoenix/channels.html#using-token-authentication)
// section to see how the token should be used.
let socket = new Socket("/socket", {authToken: window.userToken})
socket.connect()
// Now that you are connected, you can join channels with a topic.
// Let's assume you have a channel with a topic named `room` and the
// subtopic is its id - in this case demo:
let channel = socket.channel("room:demo", {})
let myUserId = null
channel.join()
.receive("ok", resp => { console.log("Joined successfully as", myUserId) })
.receive("error", resp => { console.log("Unable to join", resp) })
// Debounce cursor moves to one message per 50 ms.
let lastSent = 0
document.addEventListener("mousemove", (e) => {
const now = Date.now()
if (now - lastSent < 50) return
lastSent = now
channel.push("cursor_move", {x: e.clientX, y: e.clientY})
})
// Draw incoming cursors.
const cursors = new Map()
channel.on("cursor_move", ({user_id, x, y}) => {
let el = cursors.get(user_id)
if (!el) {
el = document.createElement("div")
el.className = "fixed pointer-events-none flex items-center"
el.innerHTML = `
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<div class="ml-1 text-xs text-blue-700 bg-white px-1 rounded">${user_id}</div>
`
document.getElementById("cursor-canvas").appendChild(el)
cursors.set(user_id, el)
}
el.style.left = `${x}px`
el.style.top = `${y}px`
})
export default socket
Three things to notice:
- Debouncing: mouse-move events fire many times per second. We rate-limit to one message every 50 ms, which is fast enough to feel real-time without flooding the server.
-
Bidirectional: events on one channel. The same channel object sends
(
push) and receives (on), so there's no separate subscription step. -
broadcast_from!: did the work. Our own cursor doesn't draw because the server doesn't echo our messages back to us.
Step 5: Run it
Start the server using the iex and mix combination command, just in case
you need to interact with the server while it is running.
iex -S mix phx.server
Open http://localhost:4000 in two browser windows side by side. Move the mouse in one, and a blue cursor with a short ID next to it should follow your mouse's movements in the other.
If nothing happens, open the browser console. You should see "Joined as
<some-id>" from the join handler. If you see an error, jump to the
Troubleshooting section below.
What just happened
A dedicated BEAM process maintains a persistent WebSocket connection from each
browser, which represents your RoomChannel. When a cursor_move event arrives,
the channel process calls broadcast_from!/3, which:
-
Looks up every process subscribed to the
topic
room:demoinPhoenix.PubSub's ETS table. -
Sends each process (except the sender's) the "cursor_move" message as
a
%Phoenix.Socket.Broadcast{}. - Each subscribed channel process forwards the message to its own WebSocket client.
That's the entire model: topics → subscribers → broadcast. Everything else in Phoenix Channels---such as heartbeats, reconnection, presence, and multi-node distribution---is built on top of those three primitives.
Troubleshooting
-
undefined function broadcast_from!/3: make sure your channel module starts with the lineuse CursorDemoWeb, :channelright after thedefmoduledefinition. Phoenix's:channelmacro is what brings the helpers into scope. -
WebSocket connection fails (browser console): confirm the endpoint allows
it. In
lib/cursor_demo_web/endpoint.ex, thesocket "/socket", CursorDemoWeb.UserSocket, websocket: true, longpoll: trueline must be present (it is, by default). - Cursors don't appear: check the browser console for a "Joined as ...." message. If you see it but no movement, the issue is on the client-side render, not the channel.
Next steps
You've built the simplest possible real-time feature. From here:
-
Add presence tracking with
Phoenix.Presenceso each user gets a stable label rather than a random hex string. - Read Channel callbacks, annotated to see what other lifecycle hooks Channels offer.
- When you're ready to operate at scale, the document Handle 100,000 concurrent connections on a single node will walk you through tuning your Linux server, BEAM, etc.