When people hear “collaborative whiteboard” or “real‑time mind maps,” they usually assume WebSockets, presence indicators, and a dedicated real‑time backend.
In this project, I built a collaborative whiteboard in Next.js using Cytoscape.js for graph rendering. But instead of WebSockets, I chose a much simpler approach: HTTP polling using HEAD requests.
This article is a case study of that decision: how the app works, what “collaboration” means in this context, how the HEAD polling strategy works, and when this trade‑off is actually better than WebSockets.
The Product: A Graph‑Based Mind‑Map Whiteboard
At a high level, Nodelandis:
- A collaborative whiteboard for mind maps, note-taking and brainstorming
- Built with Next.js on the frontend
- Using
Cytoscape.js as the graph engine and renderer
Every piece of information in the app is a node in a graph:
- Nodes: ideas, tasks, concepts, sections of a document…
- Edges: relationships between these ideas (parent/child, references, flows…)
On top of the Cytoscape canvas, there’s a typical productivity UI:
- Canvas: the mind map itself, where you zoom, pan, drag nodes, and connect ideas.
- Sidebars: to edit node details (title, description, attachments…), search, navigate.
- Toolbars: to add nodes, change styles, switch templates (mind map, tree, etc.).
Initially, editing node content happened in a side panel: you click a node on the canvas, and a sidebar opens with an input field. A future step is to support inline editing directly on the canvas, like Miro or FigJam, but the collaboration model already had to work today.
That’s where the question of synchronizing documents across users came in.
What “Collaboration” Means in This App
Before picking any technology (WebSockets, polling, etc.), it was useful to define what “collaboration” actually means for this tool.
In my case, collaboration requirements were:
- Multiple users can open the same mind map.
- Edits should propagate within a few seconds, not instant millisecond‑level latency.
- No strict operational transforms or CRDTs: last writer wins is acceptable for now.
- Conflicts are rare because most teams don’t type on the exact same node at the same time.
- Minimal extra infrastructure: I wanted to keep one application and one codebase.
This already hinted that I might not need the full power (and complexity) of WebSockets.
For many whiteboard/mind‑map experiences, “real‑time enough” is actually a 1–3 second delay. The UX matters a lot, but it doesn’t always require a full push‑based architecture.
Why I Didn’t Start With WebSockets
WebSockets are the default mental model for “real‑time” in web apps. And they are great when you truly need:
- Ultra‑low latency
- Bi‑directional streaming
- Presence indicators, cursors, chat, etc.
But they come with non‑trivial costs:
- Different protocol and infrastructure: you now have to deploy and monitor a WebSocket server (or use a managed service).
- Connection lifecycle: reconnects, heartbeats, backoff, server fan‑out, scaling.
- Client complexity: you need custom logic to subscribe/unsubscribe to documents, handle dropped connections, and backpressure.
For large teams, that’s fine. For a smaller project, this can easily become more engineering time spent on plumbing than on product value.
In my case, I had a strong constraint:
I wanted to keep a single Next.js application and codebase without introducing a separate real‑time backend or managed WebSocket service.
That constraint pushed me to ask: what’s the simplest thing that could possibly work and still feel collaborative?
The Simpler Alternative: Polling with HTTP HEAD
The strategy I ended up using is:
- Assign each document a version or last‑modified marker.
- Clients periodically send a
HEAD request to ask: “Has this document changed since I last saw it?” - If the server answers “yes” (via headers), the client then fetches the new content with a normal GET/API call and updates the canvas.
Why HEAD and not GET?
- HEAD responses do not include a body, only headers.
- That means the “Is it new?” check is very cheap in bandwidth.
- You only pull the full document when something actually changed.
On the server, a document might have fields like:
- id
- content (nodes, edges, metadata…)
- updatedAt or version number
The collaboration logic becomes:
- User A edits the mind map and saves changes.
- The server updates the document and bumps updatedAt or version.
- User B’s client is polling with HEAD every few seconds.
- When updatedAt/version changes, User B’s client notices and fetches the full document.
- The UI re‑renders the graph with the new data.
You can tune the polling interval to balance “freshness” and server load. For example:
- 2–3 seconds during active editing
- 10–15+ seconds when the document is idle or in the background
How the Polling Loop Works (Client Side)
Here’s a simplified version of how the client might implement this.
1. Track the current document version
let currentVersion: string | null = null;
let pollingIntervalId: number | null = null;
2. Start polling with HEAD when a document is open
function startDocumentPolling(documentId: string) {
if (pollingIntervalId !== null) {
window.clearInterval(pollingIntervalId);
}
pollingIntervalId = window.setInterval(async () => {
try {
const res = await fetch(`/api/documents/${documentId}`, {
method: 'HEAD',
});
if (!res.ok) return;
const serverVersion = res.headers.get('x-document-version');
if (!serverVersion) return;
// First time: just store it.
if (currentVersion === null) {
currentVersion = serverVersion;
return;
}
// If the version changed, fetch the full document
if (serverVersion !== currentVersion) {
currentVersion = serverVersion;
await refreshDocument(documentId);
}
} catch (e) {
// You can log errors or implement backoff here
console.error('Polling HEAD failed', e);
}
}, 3000); // 3 seconds, for example
}
async function refreshDocument(documentId: string) {
const res = await fetch(`/api/documents/${documentId}`);
const data = await res.json();
// Update your React state / context with the new document data
// e.g. setDocument(data)
}
3. Clean up when leaving the document
function stopDocumentPolling() {
if (pollingIntervalId !== null) {
window.clearInterval(pollingIntervalId);
pollingIntervalId = null;
}
}
In a real app, you’d probably integrate this into a React hook or context provider and handle details like window focus/blur, tab visibility, and route changes.
Implementing the HEAD Route (Server Side)
On the server (Next.js API route), the HEAD handler just needs to:
- Look up the document
- Read its version or updatedAt
- Return it in a header
Here’s a simplified example:
// /pages/api/documents/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getDocumentById } from '../../../lib/documents'; // pseudo‑code
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === 'HEAD') {
const doc = await getDocumentById(id as string);
if (!doc) {
return res.status(404).end();
}
res.setHeader('x-document-version', doc.version.toString());
return res.status(200).end();
}
if (req.method === 'GET') {
const doc = await getDocumentById(id as string);
if (!doc) {
return res.status(404).json({ error: 'Not found' });
}
res.setHeader('x-document-version', doc.version.toString());
return res.status(200).json(doc);
}
// Other methods (POST/PUT) would handle writes…
res.setHeader('Allow', 'HEAD, GET, POST, PUT');
return res.status(405).end('Method Not Allowed');
}
In your real code, the details will differ (Prisma/ORM, authentication, etc.), but the idea is the same: encode the document’s “freshness” into a header and let the client compare.
Integrating Updates into the Cytoscape Canvas
The last piece is how this polling‑based collaboration actually feels in the UI.
When refreshDocument fetches a new version of the mind map:
- The React state or context that stores the document is updated.
- Cytoscape receives the updated list of nodes and edges.
- The canvas re‑renders to reflect the changes.
A few UX considerations:
- Camera position: If another user changes the graph structure, you don’t want to randomly reset the viewer’s zoom/position each time. Keep the current viewport when possible.
- Selections: If the local user is editing a node in the sidebar, you might avoid snapping them away if that node still exists.
- Soft updates: You can animate new/changed nodes rather than hard refreshes, to make updates feel more natural.
With a polling interval of a few seconds, the experience is:
- You edit a node → others see the change appear on their canvas shortly after.
- There’s no typing cursor per character, but for a lot of use cases that’s good enough.
This feels especially acceptable when most edits modify structure (adding/removing nodes, changing relationships) rather than continuous chat‑like text.
Trade‑Offs: Polling vs WebSockets
Advantages of the HEAD Polling Approach
- Single codebase and deployment: No separate real‑time backend, no special gateway for WebSockets, no extra services to maintain.
- Simplicity: It’s all regular HTTP, with standard Next.js API routes and mostly familiar fetch logic.
- Predictable load: Polling interval is under your control. You can increase or decrease it based on usage, and even pause when the tab is inactive.
- Easy debugging: You can inspect requests in the browser dev tools and logs, without needing extra tooling for socket frames.
Limitations and When It Breaks Down
- Not instant: There’s always a small delay. If your app needs per‑keystroke synchronization or live cursors, this might feel laggy.
- Extra requests: Polling generates more “empty” checks: many HEAD calls that return “no changes”. With good headers and short responses, this is usually acceptable, but it’s not free.
- No server push: You can’t easily send “events” from the server to the client on demand (e.g. “someone invited you”, “the document was archived”).
For my current use case, these trade‑offs are well worth the simplicity and the ability to ship collaboration without re‑architecting the entire stack.
When I Would Switch to WebSockets (Or SSE)
There are scenarios where I’d seriously consider moving beyond polling:
- Live cursors and presence: seeing exactly where other users are focusing on the canvas, in real‑time.
- In‑document chat or comments: especially if they need to be highly responsive.
- High‑frequency updates: if users are editing text collaboratively at character‑level, not just high‑level nodes and edges.
- Large teams, many concurrent editors per document: where wasted polling becomes expensive.
If the product evolves in that direction, a natural path would be:
- Keep the same document versioning concept.
- Introduce a WebSocket or Server‑Sent Events (SSE) layer that pushes “document changed” events to clients, instead of polling.
- Reuse much of the existing reconciliation logic for updating the Cytoscape graph.
The nice thing is that the polling architecture doesn’t block this future: it’s a pragmatic step 1 that already delivers value.
Lessons Learned
-
Real‑time is a spectrum
For many collaborative tools, a delay of a couple seconds still feels collaborative, especially when users are restructuring ideas instead of co‑typing paragraphs.
-
Simplicity is a feature
By using HEAD polling, I kept one Next.js codebase and one deployment. That freed time to build better UX (navigation sidebars, templates, inline editing) instead of wrestling with infra.
-
Design for graceful escalation
The current architecture works well for my needs today and doesn’t rule out WebSockets later. The document version marker is a good abstraction whether you’re polling or subscribing to events.
If you’re building a collaborative front‑end app and you’re a small team (or a solo dev), it’s worth questioning the automatic “we need WebSockets” reflex. Sometimes, plain HTTP with a bit of creativity is enough to deliver a great user experience.
