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:

Every piece of information in the app is a node in a graph:

On top of the Cytoscape canvas, there’s a typical productivity UI:

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:

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:

But they come with non‑trivial costs:

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:

Why HEAD and not GET?

On the server, a document might have fields like:

The collaboration logic becomes:

  1. User A edits the mind map and saves changes.
  2. The server updates the document and bumps updatedAt or version.
  3. User B’s client is polling with HEAD every few seconds.
  4. When updatedAt/version changes, User B’s client notices and fetches the full document.
  5. The UI re‑renders the graph with the new data.

You can tune the polling interval to balance “freshness” and server load. For example:


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:

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:

A few UX considerations:

With a polling interval of a few seconds, the experience is:

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

Limitations and When It Breaks Down

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:

If the product evolves in that direction, a natural path would be:

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

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.