Real-time scenario state over WebSocket: project:init and project:save as the contract
Real-time scenario state over WebSocket: project:init and project:save as the contract
Two clients, one truth. Three message types —
project:init,project:save,error— that decide whether your collaborative editor is a feature or a bug.
A scenario in a planning tool is rarely worked on alone. An analyst opens the same scene a planner opened twenty minutes ago, and the planner opened the scene a reviewer touched the day before. The moment two browsers connect to the same project at the same time, the architecture stops being about React and starts being about a contract: which side owns the truth, who is allowed to mutate it, and what shape the wire messages take.
The repo I am writing from is a Next.js 15.2.4 app that runs a Three.js scene per project. The scene lives in the database as a single JSON blob on the Project row, and edits stream over a ws v8.18.2 WebSocket. There are two interesting files: services/ProjectWSService.ts on the server, and components/viewer/services/ProjectWSClient.ts in the browser. Both files agree on a MessageType union, and that agreement is the entire contract.
This post treats that contract as a framework. Not "use WebSockets for real-time" — that is wallpaper. The framework is: name three message types, pin them in both files, never let prose drift from the union.
The decision this framework is for
You have a long-lived editing surface — a canvas, a board, a 3D scene — and more than one human can touch it. You have already rejected polling because the user expects instant load when they open a project. You have already rejected long-lived REST because every save would have to re-handshake and your editing surface has dozens of small writes per minute.
That leaves a persistent connection. The next decision is the one most teams get wrong: what flows over that connection? "Events" is not an answer. "JSON" is not an answer. The answer has to be a typed, enumerable, exhaustive list of message shapes — small enough to print on one page and load-bearing enough that breaking it is a breaking change.
In this repo, the answer is three names: project:init, project:save, error. That is it.
The framework
I will call this the "named-tuple wire contract" — four rules:
- Single union, both sides. The server and the client must declare the same
MessageTypeliteral union. If one side adds a fourth type, the other side learns about it as a compile error, not a console warning at 2am. - Type plus payload, nothing else. Every wire message is
{ type, payload }. Noevent, noop, nokind. One discriminator. One body. - The path is the room. Authorization and scoping happen in the upgrade handler, not inside the message handler. By the time you reach
onmessage, you already know which project this socket is allowed to mutate. initis one-shot,saveis idempotent on the row.initfires once per connection, server to client.saveis the write — and because the server overwrites the same JSON column, replaying a save is harmless. No event log, no versions, no CRDT. Just last-write-wins on ascene Json?Prisma field.
The rest of this post walks each rule against the actual file content.
Each step, with the evidence
Single union, both sides
This is the line that anchors everything. It exists in services/ProjectWSService.ts:
type MessageType = 'project:init' | 'project:save' | 'error'
And it exists, identical, in components/viewer/services/ProjectWSClient.ts:
type MessageType = 'project:init' | 'project:save' | 'error'
Two declarations, one truth. The duplication is intentional in a Next.js 15 codebase because the client bundle and the server bundle are compiled independently — there is no single runtime that holds the union. The cost of duplication is one line in two files. The benefit is that when you introduce project:cursor or project:lock, TypeScript shouts on both sides until both sides know about it.
A common variant I would push back on: defining the union once in @/types and importing it. That works, and I have used it. The reason this repo does not is friction — the file ProjectWSClient.ts lives under components/viewer/services/ and only ships to the browser, and ProjectWSService.ts lives under services/ and only ships to the Node process. Keeping the union literally typed in both files makes each file readable on its own. That is a defensible trade.
Type plus payload, nothing else
The first message from server to client demonstrates the shape:
ws.send(JSON.stringify({
type: 'project:init',
payload: {
id: project.projectId,
name: project.name,
scene: project.scene,
description: project.description,
},
}));
And the client decodes it with the matching discriminator:
const message = JSON.parse(event.data)
const payload = message.payload || {}
switch (message.type) {
case 'project:init':
ProjectWSClient.init(payload)
console.log('[WS] Project initialized with scene data')
break
case 'project:save':
console.log('[WS] Project saved successfully')
break
case 'error':
console.error('[WS] Error:', message.payload)
break
default:
console.warn('[WS] Unknown message type:', message.type)
}
Notice what is not there. No version. No correlationId. No timestamp at the envelope level. Those are protocol features you graduate into when you need them — when you need ordering guarantees across reconnects, or request-response semantics over a duplex channel, or auditability. This repo doesn't need them yet, and adding them speculatively would have inflated every message on the wire by a few hundred bytes for no current return. The default branch with a console warning is the safety net: any unrecognised type is logged, not crashed on.
The path is the room
This is the part most teams skip and most teams regret. Authorization on a WebSocket connection has to happen at the HTTP upgrade, not after the socket is open. In server.ts, the upgrade handler is gated by path prefix:
server.on('upgrade', (req, socket, head) => {
const { pathname } = parse(req.url || '', true);
if (pathname && pathname.startsWith('/projects/')) {
ProjectWSService.getInstance().initialize(server);
}
});
Then inside ProjectWSService.handleConnection, the path is parsed into a project id and the project is loaded from Prisma before the socket is allowed to speak:
const match = path.match(/^\/projects\/([a-zA-Z0-9-]+)$/);
if (!match) {
console.error(`Invalid path: ${path}`);
ws.send(JSON.stringify({ error: 'Invalid path' }));
ws.close(1008, 'Invalid path');
return;
}
const projectId = match[1];
const project: Project | null = await prisma.project.findUnique({
where: { projectId: projectId },
});
if (!project) {
console.error(`Project not found for ID: ${projectId}`);
ws.send(JSON.stringify({ error: 'Project not found' }));
ws.close(1008, 'Project not found');
return;
}
By the time you reach ws.on('message', ...), you already have projectId in closure scope. The message handler does not parse a project id out of the payload — it uses the one the path gave it. That is the difference between "the client tells me what to save" and "the room tells me what to save."
This is also the cleanest place to add identity, and the most honest gap in the current implementation. There is no user check yet — the path is the room, but the room is unlocked. In production that would be an auth cookie validated in the upgrade handler, or a short-lived signed token in the query string. The pattern stays the same; the gate just gets stricter.
init is one-shot, save is idempotent on the row
This is where the contract earns its keep. The server sends project:init exactly once, immediately after the connection passes authorization. That is the entire bootstrap — no follow-up REST call from the client to fetch the scene, no race between the socket opening and the page hydrating. The scene is in the first frame.
The client handles it by clearing the live Three.js scene and rehydrating from JSON via THREE.ObjectLoader:
const sceneJSON = payload.scene
const globalScene = useViewerStore.getState().globalScene
while (globalScene.children.length > 0) {
const child = globalScene.children[0]
globalScene.remove(child)
}
const loader = new THREE.ObjectLoader()
const loadedScene = loader.parse(sceneJSON)
while (loadedScene.children.length > 0) {
const child = loadedScene.children[0]
globalScene.add(child)
}
project:save flows the other way. The client serialises the scene with globalScene.toJSON() — Three.js's built-in serializer — and sends a single message:
const message = {
type: 'project:save' as MessageType,
payload: {
name: 'My Scene',
timestamp: new Date().toISOString(),
description: projectDescription,
scene: globalScene.toJSON()
}
}
if (!ProjectWSClient.socket || ProjectWSClient.socket.readyState !== WebSocket.OPEN) {
console.warn('[WS] Save failed: WebSocket is not open')
return
}
ProjectWSClient.socket.send(JSON.stringify(message))
The server takes the payload and slams it into the scene Json? column on the Project row:
ws.on('message', async (message: string) => {
try {
const data = JSON.parse(message);
if (data.type === 'project:save') {
await prisma.project.update({
where: { projectId: projectId },
data: data.payload,
});
} else {
console.warn(`Unknown message type: ${data.type}`);
}
} catch (err) {
console.error(`Error parsing message for project ${projectId}:`, err);
}
});
That prisma.project.update is the whole persistence path. No transaction across rows, no append-only log, no diffing of the previous scene. The scene column is a single Prisma Json? field, and a save is a full overwrite. Replays are harmless because the post-state is identical. The cost is that you cannot do per-field optimistic updates server-side, and the cost is that two near-simultaneous saves from two clients will race — last one in wins, with no merge.
That race is the honest edge.
Walk the framework through a real artifact in the repo
Trace one user action end-to-end. The planner drags a hexagon overlay onto the scene and hits save.
- The browser already has a socket open at
ws://localhost:3000/projects/<projectId>, established when the page loaded. The connection was authorized inserver.ts'supgradehandler because the path matched/projects/. - The browser called
ProjectWSClient.save(). That method reads the live scene from a Zustand store (useViewerStore.getState().globalScene), serialises withglobalScene.toJSON(), and sends{ type: 'project:save', payload: { name, timestamp, description, scene } }. - The Node process receives the message inside
ProjectWSService.handleConnection. TheprojectIdis already pinned in closure — the client did not get to choose. The handler parses, switches ondata.type === 'project:save', and writesdata.payloadinto theProjectrow via Prisma. - The next client to open the project triggers a fresh upgrade, hits
findUnique, and receives the new scene in the firstproject:initframe.
Notice what is missing from the trace: no fan-out broadcast. The current ProjectWSService does not maintain a Map<projectId, Set<WebSocket>>, so a save from client A does not push to client B in real time. Client B sees A's changes the next time B opens the project. That is a deliberate simplification — and probably the next thing to add — but it does not break the contract. Adding broadcast is a server-only change. The client union doesn't move.
Where the framework fails
Three places this contract is thin, in increasing order of severity.
One: no schema validation on the wire. The server does JSON.parse(message) and trusts the shape. A malicious or buggy client could send { type: 'project:save', payload: { projectId: 'someone-elses-id' } } and — because Prisma update uses spread semantics — set fields you didn't intend. The repo already has zod v3.25.64 in dependencies. The fix is a z.object per message type, parsed before the switch. Not done yet; should be.
Two: last-write-wins on a single JSON column. Two analysts editing the same scene at the same time will overwrite each other. For a planning tool with low concurrency this is fine. For a true multiplayer editor it is not, and no amount of WebSocket plumbing will save you — you need a CRDT (Yjs, Automerge) or operational transforms, and that is a different architecture entirely. The framework holds up to two-or-three concurrent editors with social coordination ("hey, I'm editing — give me ten minutes"). It does not hold up to five.
Three: the upgrade handler initialises the WSS lazily, inside the upgrade callback. Look at this carefully:
server.on('upgrade', (req, socket, head) => {
const { pathname } = parse(req.url || '', true);
if (pathname && pathname.startsWith('/projects/')) {
ProjectWSService.getInstance().initialize(server);
}
});
initialize is called on every matching upgrade. Because ProjectWSService is a singleton and initialize reassigns this.wss, repeated calls create new WebSocketServer instances bound to the same HTTP server. The singleton hides the leak in dev, but in production the right shape is to call initialize(server) once at startup, outside the upgrade callback. The current code works because the second initialize mostly noops in practice — but "mostly" is the word that breaks systems at 3am.
The contract — the three message types and the one envelope shape — is fine. The bootstrap around it is the part to clean up.
The one prompt that triggers the framework
When you are about to ship the first real-time feature in any app, do not start with "which WebSocket library." Start with: write the MessageType union, in both files, before you write the handler. If you cannot list every wire message on a sticky note, the protocol is not designed yet — it is accreting.
For this repo, that sticky note has three words on it: init, save, error. Adding cursor or lock or presence is a deliberate act, not a drift.
Trade-off
The framework accepts that the server overwrites the scene blob on every save and that two simultaneous editors can clobber each other. In exchange you get: a hydration path that fits in one init frame, persistence that fits in one prisma.update call, and a wire protocol you can hold in your head while debugging. For a planning tool with a handful of editors per project, that is the right ratio. For a Figma-style editor with hundreds of concurrent cursors, it is not — and you would know that before choosing this shape.
Business impact
A typed wire contract in two files is the difference between "we'll add real-time later" and "real-time shipped this sprint." Senior engineers can read both files in ten minutes and ship a fourth message type without an RFC. Onboarding cost goes down. Bug reports about "the scene didn't save" arrive with the exact JSON payload attached, because there is exactly one JSON payload shape to attach. That is what a contract buys you — predictable failure modes, cheaper hires, fewer 2am rollbacks.
What to do next
Open the two files in your own codebase that talk to each other over a socket or a queue. Find the type discriminator on the wire. If it is not a type literal union declared on both sides, write it down — once on the producer, once on the consumer, identical. Then run tsc --noEmit and see whether your code already agrees with itself.
If it does, you have a contract. If it does not, you have an integration test waiting to happen.
{
"title": "Real-time scenario state over WebSocket: the project:init contract",
"metaDescription": "How a two-file MessageType union — project:init and project:save — keeps a multi-client Three.js scene in sync over a single ws connection.",
"slug": "websocket-scenario-state-sync",
"primaryKeyword": "websocket message contract",
"secondaryKeywords": ["project:init", "project:save", "websocket protocol", "real-time sync", "typed wire contract", "Next.js websocket"],
"audience": "CTOs, senior engineers, fellow practitioners",
"searchIntent": "informational",
"internalLinkTargets": ["/services", "/case-studies"],
"schema": {
"type": "BlogPosting",
"faq": [
{
"q": "Why declare the MessageType union in two files instead of importing it from a shared module?",
"a": "Server and browser bundles are compiled independently in Next.js. Duplicating the literal union — one line in each file — keeps each file readable in isolation and still surfaces drift as a compile error on both sides."
},
{
"q": "Is last-write-wins on a single JSON column enough for collaborative editing?",
"a": "For two or three editors with social coordination, yes. For five-plus concurrent editors with cursors and locking, you need a CRDT like Yjs or Automerge — no amount of WebSocket protocol design will replace conflict resolution."
},
{
"q": "Where should authorization happen on a WebSocket connection?",
"a": "In the HTTP upgrade handler, before the socket is open. The path encodes the room; the upgrade handler validates the caller's right to enter it. By the time the message handler runs, the scope is already pinned."
}
]
},
"coverImagePrompt": "Two dark terminal windows side by side connected by a horizontal dotted line; two labelled message bubbles ('init' and 'save') hover above the line; dark navy background; clean technical illustration"
}
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox