Building a Collaborative PDF Viewer
Leverage WebSockets to Build a Collaborative PDF Viewer with Nuxt and Cloudflare Workers.
Written by Thomas Karner
21.2.2026
When reviewing documents, like contracts or marketing slides, with other parties, a few central requirements come to mind.
All parties should be “on the same page”. It should be clear to everyone, what part or page of the document is being discussed. Furthermore, only one person at a time should be able to flip through the pages. This “host” role can be transferred to other parties, but only the host should be able to dictate the pace. Next, it should be easy to join and leave such document reviewing sessions. Finally, not everyone should be allowed to possess the documents being reviewed.
These requirements are easy to achieve in the real world, but harder in the online world. The obvious way would be to use collaboration software like Teams, Webex or Zoom. But these come with their own drawbacks.
First, in most cases they require extra steps to be used. Be at the need to acquire a license, to create an account or to download and install software. Second, their usage comes with an overhead. When sharing a screen, much more information is being transmitted than would be need to simply be on the same page of a document. As more data is being trasnmitted, the need for a better internet connection to view a shared document in high resolution stutter-free arises. Third, transmitting the host role involves a significant overhead. The person sharing a document must also be in posession of the document, which may violate our requirement that only certain people should be allowed to possess a document.
In this post I will therefore go over the most important aspects of creating a WebSockets-based collaborative PDF viewing tool. This is not a step-by-step tutorial, but rather a collection of thoughts that can help you in creating such a tool of your own.
Basic Architecture
My initial goal was to create and deploy a single web application that utilizes Nuxt to serve both front-end and back-end needs. Nuxt uses the Nitro toolkit for server-side work. For this application, the server acts as a hub accepting websocket messages and sending them to either all connections or only a certain connection. An event like “user A joined” should be transmitted to all other users (one-to-many relation), while a “host transfer” request, should only be transmitted from the requestor to the requestee (one-to-one relation).
Early Set-Backs
I quickly abondones this plan, as Nuxt does not currently support a Cloudflare-compatible implementation of WebSockets. There is some frustration about this topic on GitHub:
@pi0 is there an example somewhere that instructs how to set up websockets with the
cloudflare-moduleNitro preset?When using the example from the docs, if I run locally in Nuxt (via
nuxt dev) the tabs communicate properly; however, when I build and then runwrangler devthere is no communication between separate tabs.I can see each
peeris available (the number is incremented for each connected client if I output on the page) but I can not receive messages between them after callingsubscribe/publishaccordingly.I see the following note on the
CrossWS Pub / Subpage:Native pub/sub is currently only available for Bun, Node.js (uWebSockets) and Cloudflare (durable objects) but there are no instructions as to how to actually hook it up (here in the Nitro docs) to Cloudflare Durable Objects?
If you can point me in the right direction I can also push up a PR for docs.
Alternatively, if you have another (non-experimental) suggestion on how to implement similar messaging, I’d be open to try.
And the reply:
Durable-ojbect support is on the progress (sorry for misleading docs, that section is not released yet)
These conflicts become apparent when comparing the way that Nitro expects WebSocket handlers to be defined and the way that Cloudflare expects them to be defined.
Nitro requires WebSocket-handlers to be defined as:
export default defineWebSocketHandler({
open(peer) {
peer.subscribe("visitors");
peer.publish("visitors", peer.peers.size);
peer.send(peer.peers.size);
},
close(peer) {
peer.unsubscribe("visitors");
setTimeout(() => {
peer.publish("visitors", peer.peers.size);
}, 500);
},
});
While using Cloudlare Durable Objects as WebSocket servers is more verbose, but adds support for hibernation which allows durable objects to sleep while inactive to reduce costs:
import { DurableObject } from "cloudflare:workers";
export interface Env {
WEBSOCKET_HIBERNATION_SERVER: DurableObjectNamespace<WebSocketHibernationServer>;
}
// Durable Object
export class WebSocketHibernationServer extends DurableObject {
async fetch(request: Request): Promise<Response> {
// Creates two ends of a WebSocket connection.
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// Calling `acceptWebSocket()` connects the WebSocket to the Durable Object, allowing the WebSocket to send and receive messages.
// Unlike `ws.accept()`, `state.acceptWebSocket(ws)` allows the Durable Object to be hibernated
// When the Durable Object receives a message during Hibernation, it will run the `constructor` to be re-initialized
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
// Upon receiving a message from the client, reply with the same message,
// but will prefix the message with "[Durable Object]: " and return the number of connections.
ws.send(
`[Durable Object] message: ${message}, connections: ${this.ctx.getWebSockets().length}`,
);
}
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean,
) {
// Calling close() on the server completes the WebSocket close handshake
ws.close(code, reason);
}
}
A clean and maintanable alternative is therefore to create two services:
- The first service is the Nuxt app
- The second service is the WebSockets backend in the form of a Cloudflare Worker
How It Works
Let’s look at a specific example. When a new user joins a room, a message is sent to the server:
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${apiHost}/websocket
?room=${id}
&name=${userName.value}
&isHost=${isHost.value}`); /* Letting the user choose if they are host or not can lead to identity theft,
which is not a joke. But for this simple application, it is good enough. */
ws.onopen = async () => {
if (ws) {
ws.send(JSON.stringify({ type: "identity" }));
}
};
On the server side, the request is proxied from the Worker to the Durable object and a newly connected Client object is persisted.
export class WebSocketHibernationServer extends DurableObject {
sessions: Map<WebSocket, Client>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sessions = new Map();
// Wake up hibernating WebSockets and restore their state
this.ctx.getWebSockets().forEach((ws) => {
let attachment = ws.deserializeAttachment();
if (attachment) {
this.sessions.set(ws, { ...attachment });
}
});
// Set auto-response for ping/pong without waking hibernated WebSockets
this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
}
async fetch(request: Request): Promise<Response> {
// Create WebSocket pair
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// Accept the WebSocket connection with hibernation support
this.ctx.acceptWebSocket(server);
// Generate a random client ID
const id = crypto.randomUUID();
const url = new URL(request.url);
const nameParam = url.searchParams.get('name');
const isHostParam = url.searchParams.get('isHost');
let name: string;
if (nameParam) {
name = nameParam;
} else {
name = Math.random().toString(36).substring(2, 9);
}
let isHost = false;
if (isHostParam) {
isHost = isHostParam === 'true';
}
const initialClient: Client = {
id,
name,
isHost,
};
// Serialize attachment for hibernation persistence
server.serializeAttachment(initialClient);
// Add to active sessions
this.sessions.set(server, initialClient);
return new Response(null, {
status: 101,
webSocket: client,
});
}
...
}
The server reacts to the message and sends user information back to the client. Furthermore, each connection receives a “userJoined” event.
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
const client = this.sessions.get(ws);
if (!client) return;
let data: any;
try {
data = JSON.parse(message.toString());
} catch (e) {
return;
}
if (data.type === 'identity') {
ws.send(
JSON.stringify({
type: 'identity',
payload: { id: client.id, name: client.name, isHost: client.isHost },
}),
);
this.broadCastUserEvent('userJoined');
return;
}
...
}
Receiving events on the client side:
ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'identity':
clientId.value = data.payload.id;
userName.value = data.payload.name;
isHost.value = data.payload.isHost;
if (!isHost.value && !pdfDataUrl.value) {
sendMessage({ type: 'requestFile', payload: { fromId: clientId.value } });
}
break;
case 'userJoined':
const joinedUsers = data.payload.users;
users.value = joinedUsers;
userCount.value = users.value.length;
break;
...
}
}
This is the core logic of the application. One user triggers an action on their end, and via WebSockets, this action is escalated to other connected clients.
Messages other than the initial “identity” message are handled in a more general manner by the server:
// Handle message routing based on targetId
if (data.targetId) {
// Send to specific target
const targetWs = this.findWebSocketById(data.targetId);
if (targetWs) {
targetWs.send(
JSON.stringify({
type: data.type,
targetId: data.targetId,
payload: {
...data.payload,
fromId: client.id,
},
}),
);
}
} else {
// Broadcast to all clients in the room
const broadcastMessage = JSON.stringify({
type: data.type,
payload: {
...data.payload,
count: this.sessions.size,
...(data.payload?.users === undefined && {
// if the client does not supply an updated users list, the server does
users: Array.from(this.sessions.values()),
}),
},
});
this.sessions.forEach((_, connectedWs) => {
connectedWs.send(broadcastMessage);
});
}
All other features are implemented following this basic blueprint. Happy coding!
Extra
In the created application, users can either host a room or join an existing room. Before joining an existing room, a REST endpoint is called to check if the room ID exists. We need to explicitely allow the client to call this endpoint so that we don’t run into CORS issues:
const allowedOrigins = ['https://myapp.workers.dev', ...];
async fetch(request: Request): Promise<Response> {
if (request.url.includes('/check-room')) {
const exists = this.sessions.size > 0;
const origin = request.headers.get('Origin');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (origin && allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Vary'] = 'Origin';
}
return new Response(
JSON.stringify({
exists,
}),
{
status: 200,
headers,
},
);
}
...
}
Next Steps
You can try out this app for free on viewer.voidrab.com