Skip to content
Docs

Build Figma-style multiplayer cursors with WebSockets on Vercel

Learn to build Figma-style multiplayer cursors with Next.js and FastAPI, kept consistent across multiple Vercel Function instances using WebSockets and Redis.

14 min read
Last updated June 29, 2026

Build a collaborative canvas where every visitor can see who else is present and watch remote cursors move in real time, even when the backend runs across multiple Vercel Function instances. The app pairs a Next.js App Router frontend with a FastAPI WebSocket backend, deploys as a single Vercel project using Services, and uses Redis for shared state across instances.

In this guide, you'll start with a project with Next.js and FastAPI, add a browser collaboration board, wire it to a WebSocket endpoint, then make the real-time layer production-ready with Redis-backed snapshots and pub/sub.

Deploy the template now, or read on for a deeper look at how it all works.

Figma-style multiplayer cursors with WebSockets

A canvas app with real-time multiplayer cursors showing all active users.

Deploy Template
AI Assistance

I want to build a real-time multiplayer cursor app using the Next.js + FastAPI WebSockets real-time cursors template. Read the setup instructions at https://agent-resources.dev/websockets-realtime-cursors-template.md and follow them. They cover deploying the template with Redis, running it locally with Vercel Services, building on Next.js App Router and FastAPI WebSockets, and understanding how real-time cursor presence works overall.

Turn your agent into a Vercel expert with the Vercel plugin. The plugin is optional; it is not required to use this template or follow this guide.

Terminal
npx plugins add vercel/vercel-plugin

The app has two services that ship as one Vercel project:

  • The Next.js service renders the collaboration board at /.
  • The FastAPI service accepts WebSocket connections at /server/ws.
  • The browser creates a per-tab participant, connects to the socket, and sends cursor coordinates as the pointer moves.
  • FastAPI stores the latest participant state and broadcasts presence, cursor, and leave messages.
  • Redis keeps snapshots consistent across instances and carries pub/sub events between FastAPI processes.

Start from an empty workspace. Create the project folder first, then scaffold the two services inside it:

Terminal
mkdir nextjs-fastapi-real-time-collab
cd nextjs-fastapi-real-time-collab
pnpm create next-app@latest frontend --yes
vc init fastapi backend

The Next.js app owns the browser UI. The FastAPI app owns the WebSocket server.

Add the cursor interpolation dependency to the frontend package:

Terminal
pnpm --dir frontend add perfect-cursors

Create a pnpm workspace file at the repository root so root commands can install and run the frontend package cleanly:

pnpm-workspace.yaml
packages:
- frontend

Keep the root package.json as a workspace command wrapper, not as the Next.js app itself:

package.json
{
"name": "nextjs-fastapi-real-time-collab-workspace",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.13.1",
"scripts": {
"build": "pnpm --dir frontend build",
"lint": "pnpm --dir frontend lint"
}
}

At this point, the repository has the same shape the rest of the guide assumes: frontend/ for Next.js, backend/ for FastAPI, and the root for workspace and deployment configuration.

Vercel Services lets the Next.js app and the FastAPI backend deploy together while keeping each service in its natural framework. The frontend owns /; the backend is mounted under /server.

vercel.json
{
"services": {
"web": {
"root": "frontend/",
"framework": "nextjs"
},
"api": {
"root": "backend/",
"entrypoint": "app.main:app",
"framework": "fastapi"
}
},
"rewrites": [
{ "source": "/server/(.*)", "destination": { "service": "api" } },
{ "source": "/(.*)", "destination": { "service": "web" } }
]
}

The FastAPI app declares its WebSocket route as /ws, and the top-level /server/(.*) rewrite sends browser traffic to the api service. Keeping both services on one origin avoids CORS setup and lets the browser use a relative WebSocket base URL in production.

The public rewrite sends requests to the service with the /server prefix still present, so the FastAPI app strips that prefix before route matching. That lets the backend remain natural when run by itself while still serving /server/ws and /server/api/... through Services.

backend/app/main.py
class ServicePrefixMiddleware:
def __init__(self, app, prefix: str) -> None:
self.app = app
self.prefix = prefix
self.prefix_bytes = prefix.encode()
async def __call__(self, scope, receive, send):
if scope["type"] in {"http", "websocket"}:
path = scope.get("path", "")
if path == self.prefix or path.startswith(f"{self.prefix}/"):
scope = {
**scope,
"path": path[len(self.prefix) :] or "/",
"root_path": f"{scope.get('root_path', '')}{self.prefix}",
}
raw_path = scope.get("raw_path")
if isinstance(raw_path, bytes) and raw_path.startswith(self.prefix_bytes):
scope["raw_path"] = raw_path[len(self.prefix_bytes) :] or b"/"
await self.app(scope, receive, send)
app.add_middleware(ServicePrefixMiddleware, prefix="/server")
app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(collaboration_router)

The backend's own routes stay short: the versioned API router serves /api/v1, and the collaboration router serves /ws. Through Services, those become /server/api/v1 and /server/ws.

The landing page renders the collaboration board and leaves browser-specific behavior to the Client Component.

frontend/app/page.tsx
import { CollabCanvas } from "./collab-canvas";
export default function Home() {
return <CollabCanvas />;
}

CollabCanvas is marked with "use client" because it uses state, effects, WebSocket, sessionStorage, ResizeObserver, and pointer events.

frontend/app/collab-canvas.tsx
"use client";
import { PerfectCursor } from "perfect-cursors";
import {
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";

The root layout handles metadata and fonts with next/font, so the realtime component can stay focused on socket state and rendering.

For brevity, this guide does not inline every part of the App Router shell. The full repo also includes:

  • app/layout.tsx: Geist Sans and Geist Mono setup, the root <html> element, and the shared body classes.
  • app/globals.css: Tailwind CSS import, font variables, and global body colors.
  • app/page.tsx: the single public route that renders the board.

Each browser tab gets a visitor id, name, and cursor color. The app stores that identity in sessionStorage, which means a reload keeps the same cursor while a second tab behaves like a separate collaborator.

frontend/app/collab-canvas.tsx
const colors = [
"#1e9df5",
"#7b61ff",
"#22c079",
"#fb6e6e",
"#f5a623",
"#ff4fa3",
"#14b8c4",
"#5566ff",
];
const names = ["Mira", "Kai", "Rin", "Avery", "Noor", "Sol", "Ira", "Jules"];
const sessionUserKey = "collab-session-user";

The first render uses a placeholder user. A browser-only effect then creates or restores the real session user.

frontend/app/collab-canvas.tsx
useEffect(() => {
const timer = window.setTimeout(() => {
setSelf(createLocalUser());
setIsReady(true);
}, 0);
return () => window.clearTimeout(timer);
}, []);

createLocalUser restores a saved tab identity when it can, otherwise it creates a fresh one:

frontend/app/collab-canvas.tsx
function createLocalUser(): PresenceUser {
const savedUser = sessionStorage.getItem(sessionUserKey);
if (savedUser) {
try {
const parsedUser = JSON.parse(savedUser) as Partial<PresenceUser>;
if (parsedUser.id && parsedUser.name && parsedUser.color) {
return {
id: parsedUser.id,
name: parsedUser.name,
color: parsedUser.color,
x: 0.5,
y: 0.5,
updatedAt: Date.now() / 1000,
};
}
} catch {
sessionStorage.removeItem(sessionUserKey);
}
}
const randomIndex = Math.floor(Math.random() * names.length);
return {
id: crypto.randomUUID(),
name: names[randomIndex],
color: colors[randomIndex % colors.length],
x: 0.5,
y: 0.5,
updatedAt: Date.now() / 1000,
};
}

After the visitor is ready, the app writes the stable identity back to sessionStorage whenever the name or color changes.

frontend/app/collab-canvas.tsx
useEffect(() => {
if (!isReady) {
return;
}
sessionStorage.setItem(
sessionUserKey,
JSON.stringify({ id: self.id, name: self.name, color: self.color }),
);
}, [isReady, self.id, self.name, self.color]);

For brevity, this guide does not inline every identity helper. The complete file also includes:

  • defaultUser, which gives React a stable placeholder before sessionStorage is available.
  • updateName, which trims names to 28 characters before updating local state.
  • updateColor, which changes the local cursor color and triggers the socket effect with the latest profile.
  • parseSocketMessage, which ignores invalid client-side JSON frames.

The browser connects to /server/ws by default. When the frontend and backend run as separate local servers, NEXT_PUBLIC_WS_URL can point directly at FastAPI.

frontend/app/collab-canvas.tsx
function createWebSocketUrl(user: Pick<PresenceUser, "id" | "name" | "color">) {
const baseUrl = process.env.NEXT_PUBLIC_WS_URL ?? "/server";
const url = new URL(
`${baseUrl.replace(/\/$/, "")}/ws`,
window.location.origin,
);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("id", user.id);
url.searchParams.set("name", user.name);
url.searchParams.set("color", user.color);
return url.toString();
}

The connection effect opens the socket after the visitor identity is ready. On open, it sends a hello message with the current name and color. On close, it reconnects with a capped backoff.

frontend/app/collab-canvas.tsx
useEffect(() => {
if (!isReady) {
return;
}
let cancelled = false;
const connect = () => {
const ws = new WebSocket(
createWebSocketUrl({ id: selfId, name: selfName, color: selfColor }),
);
socketRef.current = ws;
setConnectionState("connecting");
ws.addEventListener("open", () => {
reconnectDelayRef.current = 800;
setConnectionState("open");
ws.send(
JSON.stringify({ type: "hello", name: selfName, color: selfColor }),
);
});
ws.addEventListener("close", () => {
setConnectionState("closed");
if (cancelled) {
return;
}
reconnectTimerRef.current = setTimeout(
connect,
reconnectDelayRef.current,
);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.6,
8000,
);
});
};
connect();
}, [isReady, selfId, selfName, selfColor]);

The full effect also parses incoming socket messages, reduces them into the user map, clears reconnect timers on unmount, clears pending cursor timers, and closes the socket when the component leaves the page.

The board sends normalized cursor coordinates between 0 and 1. That makes cursor positions independent of each visitor's screen size.

frontend/app/collab-canvas.tsx
const sendCursor = (event: PointerEvent<HTMLDivElement>) => {
const board = boardRef.current;
const socket = socketRef.current;
if (!board || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const rect = board.getBoundingClientRect();
const x = clamp((event.clientX - rect.left) / rect.width);
const y = clamp((event.clientY - rect.top) / rect.height);
scheduleCursorSend(x, y);
};

Cursor messages are throttled to one send every 80ms. The client still updates its own state before sending so the local cursor feels immediate.

frontend/app/collab-canvas.tsx
const cursorSendIntervalMs = 80;
const flushCursor = () => {
cursorSendTimerRef.current = null;
const pendingCursor = pendingCursorRef.current;
const socket = socketRef.current;
if (!pendingCursor || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
pendingCursorRef.current = null;
lastSentAtRef.current = Date.now();
const nextSelf = {
...selfRef.current,
...pendingCursor,
updatedAt: Date.now() / 1000,
};
setSelf(nextSelf);
setUsers((currentUsers) => ({ ...currentUsers, [nextSelf.id]: nextSelf }));
socket.send(
JSON.stringify({
type: "cursor",
x: pendingCursor.x,
y: pendingCursor.y,
}),
);
};

scheduleCursorSend keeps the most recent pointer position in a ref and flushes it when the throttle window allows another send.

The server sends cursor snapshots as discrete points. The client uses perfect-cursors to animate each remote cursor between those points.

frontend/app/collab-canvas.tsx
function RemoteCursor({
boardSize,
user,
}: {
boardSize: BoardSize;
user: PresenceUser;
}) {
const cursorRef = useRef<HTMLDivElement | null>(null);
const perfectCursorRef = useRef<PerfectCursor | null>(null);
const { height, width } = boardSize;
const { x, y } = user;
const [initialPoint] = useState(() => [x * width, y * height]);
const moveCursor = useCallback((point: number[]) => {
const cursor = cursorRef.current;
if (!cursor) {
return;
}
cursor.style.transform = getCursorTransform(point);
}, []);
useEffect(() => {
const perfectCursor = new PerfectCursor(moveCursor);
perfectCursorRef.current = perfectCursor;
perfectCursor.addPoint(initialPoint);
return () => {
perfectCursor.dispose();
perfectCursorRef.current = null;
};
}, [initialPoint, moveCursor]);
useEffect(() => {
perfectCursorRef.current?.addPoint([x * width, y * height]);
}, [height, width, x, y]);
}

The sidebar is just a view of the current user map. The current visitor is shown first, and remote visitors are sorted by name.

frontend/app/collab-canvas.tsx
const otherUsers = useMemo(
() =>
Object.values(users)
.filter((user) => user.id !== selfId)
.sort((a, b) => a.name.localeCompare(b.name)),
[selfId, users],
);

For brevity, this guide does not inline every part of the board UI. The full file also includes:

  • The board header, status badge, name input, and color swatches.
  • The grid canvas and static prototype cards that make cursor movement easy to see.
  • PresenceRow, which renders each visitor in the sidebar.
  • ResizeObserver state so remote cursor coordinates scale with the actual board size.
  • statusLabel and statusClassName, which turn socket state into compact UI.

The FastAPI route accepts the socket, creates a participant from query parameters, stores that participant, sends an initial snapshot, and announces the join.

backend/app/api/routes/collaboration.py
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
presence_store = await get_store()
await presence_store.start_listener(handle_remote_event)
participant = Participant(
id=clean_text(websocket.query_params.get("id"), fallback=f"user-{time.time_ns()}"),
name=clean_text(websocket.query_params.get("name"), fallback="Visitor"),
color=clean_color(websocket.query_params.get("color"), fallback="#2563eb"),
x=0.5,
y=0.5,
updated_at=time.time(),
)
async with clients_lock:
clients[participant.id] = websocket
await presence_store.upsert(participant)

The initial snapshot gives the new client a complete roster. Then presence tells everyone else that this participant joined.

backend/app/api/routes/collaboration.py
await websocket.send_json(
{
"type": "snapshot",
"selfId": participant.id,
"users": await presence_store.snapshot(),
"stateStore": presence_store.label,
}
)
await announce({"type": "presence", "user": serialize_user(participant)}, skip_id=participant.id)

Full snapshots make reconnects simple. A reconnecting client does not need to replay missed events; it can replace local state from the latest snapshot.

The browser sends two message types:

  • hello updates the participant's name and color.
  • cursor updates the participant's normalized pointer position.
backend/app/api/routes/collaboration.py
while True:
message = parse_message(await websocket.receive_text())
if not message:
continue
if message.get("type") == "hello":
participant.name = clean_text(message.get("name"), fallback=participant.name)
participant.color = clean_color(message.get("color"), fallback=participant.color)
participant.updated_at = time.time()
await presence_store.upsert(participant)
await announce({"type": "presence", "user": serialize_user(participant)})
if message.get("type") == "cursor":
participant.x = clamp_float(message.get("x"), fallback=participant.x)
participant.y = clamp_float(message.get("y"), fallback=participant.y)
participant.updated_at = time.time()
await announce_realtime(
presence_store,
{"type": "cursor", "user": serialize_user(participant)},
skip_id=participant.id,
)
await schedule_cursor_snapshot(presence_store, participant)

The client receives four message types:

  • snapshot replaces the local user map.
  • presence adds or updates a participant.
  • cursor updates a participant's coordinates.
  • leave removes a participant.
frontend/app/collab-canvas.tsx
function reducePresence(
currentUsers: Record<string, PresenceUser>,
message: SocketMessage,
selfId: string,
) {
if (message.type === "snapshot") {
return Object.fromEntries(message.users.map((user) => [user.id, user]));
}
if (message.type === "presence" || message.type === "cursor") {
return { ...currentUsers, [message.user.id]: message.user };
}
if (message.type === "leave" && message.id !== selfId) {
const nextUsers = { ...currentUsers };
delete nextUsers[message.id];
return nextUsers;
}
return currentUsers;
}

On disconnect, the backend removes the socket, cancels any delayed cursor persistence task, deletes the participant from the store, and broadcasts a leave event.

backend/app/api/routes/collaboration.py
finally:
async with clients_lock:
clients.pop(participant.id, None)
await cancel_cursor_snapshot(participant.id)
await presence_store.remove(participant.id)
await announce({"type": "leave", "id": participant.id})

These snippets focus on the core collaboration messages. The full files also include:

  • parse_message, which accepts Redis byte payloads and WebSocket text frames.
  • clean_text, clean_color, and clamp_float, which keep untrusted socket input bounded.
  • serialize_user, which converts Python updated_at into the frontend's updatedAt.

The memory store makes local development quick because it does not require Redis. It is enough when one FastAPI process holds all connected sockets.

backend/app/api/routes/collaboration.py
class InMemoryPresenceStore:
label = "memory"
def __init__(self) -> None:
self.participants: dict[str, Participant] = {}
self.lock = asyncio.Lock()
async def snapshot(self) -> list[dict[str, Any]]:
async with self.lock:
self.prune_stale()
return [serialize_user(user) for user in self.participants.values()]
async def upsert(self, participant: Participant) -> None:
async with self.lock:
self.participants[participant.id] = participant
async def remove(self, participant_id: str) -> None:
async with self.lock:
self.participants.pop(participant_id, None)

The memory store uses the same interface as Redis, so the WebSocket route does not need to know which persistence backend is active.

backend/app/api/routes/collaboration.py
class PresenceStore(Protocol):
label: str
async def snapshot(self) -> list[dict[str, Any]]:
...
async def upsert(self, participant: Participant) -> None:
...
async def remove(self, participant_id: str) -> None:
...
async def publish(self, event: dict[str, Any]) -> None:
...
async def start_listener(self, on_event: Any) -> None:
...

The store factory chooses Redis when REDIS_URL exists and falls back to memory otherwise.

backend/app/api/routes/collaboration.py
async def get_store() -> PresenceStore:
global store
async with store_lock:
if store is not None:
return store
redis_url = os.environ.get("REDIS_URL")
if redis_url:
redis_store = RedisPresenceStore(redis_url)
await redis_store.connect()
store = redis_store
else:
store = InMemoryPresenceStore()
logger.info("Collaboration persistence backend: %s", store.label)
return store

Redis is optional locally. Without REDIS_URL, FastAPI uses the in-memory store. That is enough for one local backend process, but in production it does not share state across many Vercel Function instances.

To set up Redis, run the following command:

Terminal
vercel link

vercel integration add upstash

When prompted, select Upstash for Redis.

Now, pull the development environment variables by running the following command:

Terminal
vercel env pull

This command creates a .env.local file with the REDIS_URL environment variable.

Redis stores the latest participant payloads in a hash and their last activity timestamps in a sorted set.

backend/app/api/routes/collaboration.py
PRESENCE_HASH_KEY = "collab:presence:users"
PRESENCE_ACTIVITY_KEY = "collab:presence:activity"
STALE_AFTER_SECONDS = 120

Writing a participant updates both structures and adds expirations so old demo state cannot live forever.

backend/app/api/routes/collaboration.py
async def upsert(self, participant: Participant) -> None:
payload = json.dumps(serialize_user(participant))
pipe = self.redis.pipeline()
pipe.hset(PRESENCE_HASH_KEY, participant.id, payload)
pipe.zadd(PRESENCE_ACTIVITY_KEY, {participant.id: participant.updated_at})
pipe.expire(PRESENCE_HASH_KEY, STALE_AFTER_SECONDS * 4)
pipe.expire(PRESENCE_ACTIVITY_KEY, STALE_AFTER_SECONDS * 4)
await pipe.execute()

Reading a snapshot first removes stale ids, then returns the current roster.

backend/app/api/routes/collaboration.py
async def snapshot(self) -> list[dict[str, Any]]:
await self.prune_stale()
return [json.loads(user) for user in await self.redis.hvals(PRESENCE_HASH_KEY)]

Clean leaves remove the user from both Redis structures:

backend/app/api/routes/collaboration.py
async def remove(self, participant_id: str) -> None:
pipe = self.redis.pipeline()
pipe.hdel(PRESENCE_HASH_KEY, participant_id)
pipe.zrem(PRESENCE_ACTIVITY_KEY, participant_id)
await pipe.execute()

Cursor movement is high-volume, so the backend publishes cursor events immediately but writes cursor snapshots to Redis on a small delay.

backend/app/api/routes/collaboration.py
async def schedule_cursor_snapshot(presence_store: PresenceStore, participant: Participant) -> None:
if presence_store.label == "memory":
return
async with cursor_snapshot_lock:
task = cursor_snapshot_tasks.get(participant.id)
if task and not task.done():
return
cursor_snapshot_tasks[participant.id] = schedule_background(
persist_cursor_snapshot(presence_store, participant),
"persist throttled cursor snapshot",
)

This gives reconnecting clients a recent cursor position without turning every pointer frame into a Redis write.

Redis storage gives every instance the same snapshot, but it does not push live messages to sockets held by another instance. Pub/sub handles that fanout.

backend/app/api/routes/collaboration.py
PRESENCE_CHANNEL = "collab:presence:events"
INSTANCE_ID = uuid.uuid4().hex

Every published event includes the origin instance id:

backend/app/api/routes/collaboration.py
async def publish(self, event: dict[str, Any]) -> None:
await self.redis.publish(PRESENCE_CHANNEL, json.dumps({"origin": INSTANCE_ID, "event": event}))

Each Redis-backed instance subscribes to the shared channel and ignores events it published itself.

backend/app/api/routes/collaboration.py
async for message in pubsub.listen():
payload = parse_message(message.get("data"))
if not payload or payload.get("origin") == INSTANCE_ID:
continue
event = payload.get("event")
if isinstance(event, dict):
await on_event(event)

The broadcast path is the same whether an event came from the local socket or a remote Redis message:

backend/app/api/routes/collaboration.py
async def announce(payload: dict[str, Any], skip_id: str | None = None) -> None:
await broadcast(payload, skip_id=skip_id)
await (await get_store()).publish(payload)
async def handle_remote_event(payload: dict[str, Any]) -> None:
await broadcast(payload)

The sequence is:

  1. A user moves their cursor.
  2. The FastAPI instance sends the cursor event to its own connected clients.
  3. The instance publishes the cursor event to Redis.
  4. Other FastAPI instances receive the pub/sub event.
  5. Each instance broadcasts the event to its own connected clients.

This guide highlights the core pieces for the Redis integration. The full file also includes:

  • TLS CA configuration for rediss:// Redis URLs through certifi.
  • A listener lock so only one pub/sub task starts per process.
  • A reconnect loop for Redis pub/sub failures.
  • schedule_background, which logs async task failures instead of dropping them silently.
  • Stale-client cleanup when a WebSocket send fails during broadcast.

Clone the repository. Then install frontend dependencies:

Terminal
pnpm install

Install backend dependencies from backend/:

Terminal
cd backend
uv sync
cd ..

Run the project Services locally with the Vercel CLI:

Terminal
vercel dev -L

Open http://localhost:3000 in two browser tabs. Move your cursor across the board.

You should see:

  • Each tab appears in the visitor list.
  • Remote cursors move smoothly across the board.
  • Changing a name or color updates the other tab.
  • Closing a tab removes that participant.
Figma-style multiplayer cursor with WebSockets

The vercel.json defines the two services Vercel needs:

ServiceEntrypointRoute prefixFramework
webfrontend//nextjs
apibackend/app/main.py/serverfastapi

Add REDIS_URL in Vercel Environment Variables for shared presence across instances. Deploy the project:

Terminal
vercel --prod

Open the production URL on two devices. The browser should connect over wss:// to the same origin, and both devices should share one presence roster.

Cause: vercel.json has an unsupported service property, often from older Services examples.

Fix: Keep only supported service keys like root, framework, entrypoint, bindings, functions, and routing keys. For more information, see Services docs.

Cause: The public rewrite sends the /server prefix into the api service.

Fix: Confirm ServicePrefixMiddleware is registered in backend/app/main.py and strips /server for both HTTP and WebSocket scopes.

Cause: The Next.js app lives in frontend/; the repo root is only a workspace wrapper.

Fix: Use vercel dev -L for Services, or pnpm --dir frontend dev for split frontend-only development.

Cause: pnpm is trying to confirm module cleanup or is using stale root package state.

Fix: Run from the repository root after the frontend/ move, keep .npmrc with confirm-modules-purge=false, and confirm pnpm-lock.yaml has a frontend importer.

Cause: NEXT_PUBLIC_WS_URL is set in a combined Services deployment.

Fix: Remove NEXT_PUBLIC_WS_URL for normal Vercel Services deployments. The browser should use same-origin /server/ws.

Normalized coordinates let every client render the cursor relative to its own board size. A cursor at { "x": 0.5, "y": 0.5 } appears in the center no matter how large the viewer's screen is.

sessionStorage is scoped to a browser tab and survives reloads. That makes local testing natural: two tabs look like two collaborators, while a reload keeps the same cursor identity.

No. The in-memory store is enough for one local FastAPI process. Redis is needed when presence must be shared across multiple FastAPI instances, devices, or deployed environments.

Cursor events need low latency, but cursor snapshots only need to be recent enough for reconnects. Pub/sub carries the live event immediately, while the throttled Redis write keeps the shared snapshot fresh without writing every pointer frame.

Yes. Add a room or document ID to the WebSocket URL, include it in Redis keys and pub/sub channels, and only broadcast events to clients connected to the same room. Check out the Notion-style real-time presence guide for room-based implementation.

Was this helpful?

supported.