Whiteboards, shared docs, Kanban boards, design canvases: the last few years have produced a whole category of software where several people edit the same thing at the same time, and everyone expects it to just work. That expectation is deceptively hard to build against. A form that saves on submit is forgiving of a lot of small mistakes. A canvas where five people are dragging cards, typing in sticky notes, and watching each other’s cursors move in real time is not.
Teams building these products keep converging on the same technical decision, almost regardless of industry or team size: TypeScript on both the client and the server. Not because it’s trendy, but because collaborative software fails in ways that a type system is unusually well suited to catch before they reach production.
What Makes Collaborative Apps Harder Than Typical Web Apps
Most web applications deal with request and response. A user submits a form, the server validates it, something gets saved, the page updates. Collaborative apps add several problems that a typical CRUD app never has to deal with at all.
State is shared and mutable across multiple clients at once, not owned by a single request. Messages between client and server arrive continuously, and not necessarily in the order they were sent. Every user session carries two kinds of state that both need to stay in sync: the actual document (board content, text, shapes) and ephemeral presence data (who’s online, where their cursor is, what they have selected). Conflicts are normal, not exceptional, because two people editing the same card at the same moment is an expected use case, not an edge case to guard against.
None of this is exotic. It’s just a much larger surface area for small mismatches to cause real bugs, and those bugs tend to show up as "the board looks different for two people looking at the same screen," which is about the worst possible failure mode for a collaboration product to have.
Why locking isn’t an option
Unlike traditional request-response applications, collaborative editors can’t rely on locking to prevent concurrent edits: there’s no single moment to grab a lock when five people are already mid-edit on the same board. This is why libraries such as Yjs implement CRDTs, conflict-free replicated data types, instead of a locking scheme. A CRDT lets every client apply edits to its own local copy independently, offline if needed, and guarantees that once all the updates have propagated, every client converges on the exact same state, regardless of the order the edits arrived in. That convergence guarantee is what makes "five people editing the same card at once" a solved problem instead of a race condition to defend against by hand.
A typical setup around this looks roughly like:
Browser | v WebSocket | v Node.js server | v Yjs document (CRDT) | v Persistence (database / object storage)Persistence (database / object storage)
The server in this picture is mostly a relay and a persistence point. The actual conflict resolution happens inside the CRDT document itself, on both the client and the server, which is exactly why the shape of that document deserves the same typing discipline as any other part of the system.
Where Things Break Without Strong Typing
In several production collaborative apps, the same category of bug keeps showing up, and it’s rarely a logic error. It’s a shape mismatch. A backend event gets a new optional field, the frontend handler still assumes the old shape, and for a while nothing crashes, it just silently ignores or misreads the new data until someone notices the board state has drifted for a subset of users.
Presence payloads are a common source of this. A cursor-position event might carry { userId, x, y } on day one and { userId, x, y, selection } three months later. In a loosely typed codebase, that change is easy to make on the server and easy to miss on twelve different client-side handlers that read the same event. In a typed codebase, the compiler stops the deploy the moment a handler doesn’t account for the new field.
The other recurring pattern is message-ordering bugs disguised as "flaky" behavior. A client applies an update, then a moment later applies a stale update that arrived late over a slow connection, and the UI briefly shows the wrong state. Types don’t fix network ordering by themselves, but a well-typed event system makes it much easier to model versioned or timestamped updates explicitly instead of assuming messages always arrive in the order they were sent.
How TypeScript Actually Helps, Layer by Layer
Modeling real-time events as discriminated unions
Most collaborative apps push a stream of different event types over the same WebSocket connection: cursor moves, content edits, user joins, user leaves. Modeling these as a discriminated union, rather than one loosely typed message object, means the compiler forces every handler to account for every event type:
typescript
type BoardEvent =
| { type: "cursor_move"; userId: string; x: number; y: number }
| { type: "card_update"; cardId: string; patch: Partial }
| { type: "user_left"; userId: string };
function handleEvent(event: BoardEvent) {
switch (event.type) {
case "cursor_move":
return updateCursor(event.userId, event.x, event.y);
case "card_update":
return applyPatch(event.cardId, event.patch);
case "user_left":
return removePresence(event.userId);
default: {
const exhaustiveCheck: never = event;
throw new Error(`Unhandled event: ${JSON.stringify(exhaustiveCheck)}`);
}
}
}
That never in the default branch isn’t decoration. It means that the day someone adds a fourth event type to the union and forgets to update this switch statement, the build fails instead of the new event silently falling through unhandled in production.
Type safety across the network boundary, not just inside it
TypeScript’s types disappear at runtime, which is exactly why they’re not enough on their own at a network boundary. A WebSocket message coming from the wire is just a string until something validates it. This is where pairing TypeScript with a schema library like Zod matters: the same schema defines both the compile-time type and the runtime validation, so a malformed or unexpected payload gets rejected explicitly instead of quietly matching a type it was never actually validated against.
typescript
import { z } from "zod";
const CardUpdateSchema = z.object({
type: z.literal("card_update"),
cardId: z.string(),
patch: z.object({ text: z.string().optional(), color: z.string().optional() }),
});
type CardUpdate = z.infer;
function parseIncoming(raw: string): CardUpdate {
return CardUpdateSchema.parse(JSON.parse(raw));
}
Teams using tRPC on top of this get the same guarantee for regular API calls: the frontend imports the backend’s router types directly, so a renamed field or changed return type breaks the frontend build immediately instead of failing quietly in a browser months later.
Typing shared document state, not just messages
Libraries like Yjs, which many collaborative editors and whiteboards use under the hood for conflict-free shared state, ship with TypeScript definitions that extend to the shared document structure itself, not just the transport layer. That matters because the shared document is the thing every client is rendering from. Typing it means a card’s properties, a text node’s attributes, or a shape’s dimensions are consistent everywhere they’re read, instead of relying on every component to independently assume the same untyped shape.
Where This Breaks Down in Practice
TypeScript alone doesn’t guarantee any of this. Two mistakes account for most of the real-world cases where teams have TypeScript in the codebase and still ship the exact bugs described above.
The first is treating the WebSocket or event bus boundary as "already trusted" and typing it with any or a loose Record<string, unknown> just to get something compiling quickly. That defeats the entire purpose: the boundary between client and server, or between services, is exactly where types matter most, because it’s the one place a mismatch can’t be caught by reading the code, since the two sides are often written and deployed independently.
The second is using types purely as documentation without runtime validation anywhere. A type annotation on a function parameter guarantees nothing about what arrives over the network. Teams that skip a validation layer like Zod at the boundary end up with types that describe what the data should look like, and no actual guarantee that it does, which quietly reintroduces the exact class of bug static typing was supposed to prevent.
What This Looks Like at a Glance
| Layer | Common failure without types | What TypeScript addresses |
|---|---|---|
| Real-time events | New event field ignored by some handlers | Discriminated unions + exhaustive switch |
| Network boundary | Malformed payload silently misread | Shared schema (Zod) for compile-time and runtime checks |
| Client/server API | Renamed field breaks frontend silently | End-to-end types via tRPC |
| Shared document state | Inconsistent shape across components | Typed CRDT document (e.g., Yjs types) |
| Presence data | Stale or partial cursor/selection state | Explicit optional fields, no implicit any |
A Few Practices That Have Held Up Well
Teams that get real value out of TypeScript in collaborative products tend to share a few habits. They define event and message types in one shared package imported by both client and server, rather than maintaining parallel definitions by hand. They validate every payload at the boundary with something like Zod, even when the type already looks correct, because the type is a compile-time promise and the validator is the runtime enforcement of it. And they use exhaustiveness checks (never) on every switch over an event union, specifically so that adding a new event type is a compiler error everywhere it isn’t handled, not a bug report three weeks later.
Bringing In the Right Expertise
Getting this architecture right on the first attempt is genuinely hard, and it’s a different skill set than typical CRUD-focused web development: it touches real-time infrastructure, conflict resolution, and disciplined type design across an entire codebase at once. Organizations building their first collaborative platform often bring in engineers with prior experience designing real-time systems, since avoiding architectural mistakes early is usually far less expensive than redesigning synchronization logic once the product is already in production. That’s part of why working with an established typescript development company is a common route for teams entering this space for the first time, rather than a niche outsourcing decision.
Closing Thoughts
Real-time collaboration isn’t a UI feature bolted onto a normal app. It’s a different set of engineering problems, and the bugs it produces are the kind that are expensive to debug after the fact and cheap to prevent at the type level.
TypeScript doesn’t make distributed systems easier. It makes distributed systems easier to evolve. That’s the actual reason it keeps winning this particular argument: not because static typing is fashionable, but because in a product where dozens of clients are reading and writing the same shared state at once, catching a shape mismatch at build time beats discovering it as a support ticket about a board that "looks wrong" for one specific user.
Published: August 19, 2026
