š Executive Summary
TL;DR: Multiplayer applications frequently encounter real-time state synchronization issues due to network latency and race conditions, causing client desynchronization. The article introduces Martinit-Kit as a Typescript runtime that facilitates robust state synchronization, primarily through the authoritative server model, establishing a single source of truth for all connected users.
šÆ Key Takeaways
- Network latency is the fundamental cause of state desynchronization in multiplayer applications, leading to race conditions when multiple users attempt to modify the same state concurrently.
- The Authoritative Server model is the industry standard for robust state synchronization, where the server acts as the sole source of truth, validating intents and broadcasting official state changes to all clients.
- Optimistic UI enhances perceived performance by updating the clientās screen instantly, but it carries the risk of jarring rollbacks if the server rejects the change, making it best suited for low-conflict actions.
- Event Sourcing provides an immutable log of all state-changing actions, offering perfect audit trails and the ability to replay history, but it represents a significant architectural shift for complex systems.
Struggling with real-time state synchronization in your multiplayer Typescript app? Iām breaking down why itās so painful and offering three battle-tested solutions, from quick client-side tricks to robust server-side architectures.
That Desync Feeling: Fixing Multiplayer State Nightmares
Iāll never forget the demo for āProject Chimeraā. We were showing our new collaborative design tool to the VPs. Everything was smooth until two execs tried to move the same component at the same time. On one screen, it snapped left; on the other, it went right. Then, for a glorious ten seconds, it flickered between both spots before vanishing entirely into the digital ether. The silence in that room was⦠deafening. Thatās the day I learned that shared state isnāt a feature; itās a distributed systems boss battle, and youād better come prepared.
So, Why Does This Keep Happening? The Root of the Problem.
You see a Reddit thread about a cool new library like āMartinit-Kitā and think itās a silver bullet. It might be, but to use it effectively, you need to understand the beast youāre fighting. The core issue isnāt your code; itās physics. The internet isnāt instant.
Hereās the breakdown:
-
User A clicks a button. Their client sends a āchange stateā message to your server,
api-gw-01. - That message takes 80ms to travel. During those 80ms, the world keeps spinning.
- User B, who hasnāt received User Aās update yet, clicks a different button that modifies the same piece of state. Their message is now on its way.
- The server gets two conflicting instructions. Who wins? Does the last one in overwrite the first? What if the first one was more important?
This is a classic race condition. Without a single, undisputed source of truth and a clear set of rules for applying changes, your clients will inevitably drift apart, leading to chaos, confusion, and disappearing components during VP demos.
The Battle Plan: Three Ways to Slay the Desync Dragon
Over the years, weāve developed a few standard plays for this problem at TechResolve. They range from āget it working for the presentation tomorrowā to āre-architect for a million concurrent users.ā
Solution 1: The Quick Fix ā āOptimistic UIā
This is the āfake it ātil you make itā approach, and honestly, itās great for perceived performance. The idea is to update the userās own screen immediately, assuming the server will agree. You send the update to the server in the background and hope for the best.
// Super simplified pseudo-code
function handleMoveButtonClick(itemId, newPosition) {
// 1. Update our own UI instantly. Feels fast!
const previousPosition = updateLocalItemPosition(itemId, newPosition);
// 2. Now, tell the server what we did.
api.sendItemMove(itemId, newPosition)
.catch(error => {
// 3. Whoops, server rejected it! Roll back our optimistic change.
console.error("Move rejected by server:", error);
updateLocalItemPosition(itemId, previousPosition); // Jumps back!
showErrorToast("Couldn't move the item.");
});
}
Pro Tip: This feels magically fast to the user, but be warned. If the server rejects the change (e.g., due to a permissions issue or conflict), the UI element will ājumpā back to its original position. This can be jarring, so use it for low-conflict actions.
Solution 2: The Permanent Fix ā The āAuthoritative Serverā Model
This is the grown-up solution and the one you should build toward. In this model, the client is ādumb.ā It doesnāt decide the state; it only sends intents to the server. The server is the one and only source of truth.
The flow looks like this:
- User clicks āMove Item Left.ā
- The client sends a message like
{ action: 'MOVE_INTENT', itemId: 'abc-123', direction: 'left' }. The UI might show a spinner, but it does not move the item yet. - Your server (e.g.,
game-state-worker-03) receives the intent. It validates it, checks for conflicts, and updates the canonical state in its memory or in a fast cache like Redis. - The server then broadcasts the new, official state to all connected clients (including the one that sent the intent).
- All clients receive the new state and render it. Everyone is perfectly in sync because they are all just mirrors of the server.
Frameworks like the Martinit-Kit mentioned in that thread are built specifically to make this pattern easier. They handle the boilerplate of websockets, state broadcasting, and reconciliation, letting you focus on the server-side logic, which is the heart of the matter.
Solution 3: The āNuclearā Option ā Event Sourcing
Sometimes, the state logic is so complex that just storing the ācurrent stateā isnāt enough. What if you need to know how it got that way? Enter Event Sourcing. Instead of storing the final result, you store every single action (event) that ever happened in an immutable log.
Imagine a bank account. Instead of a database row that says balance: $50, you store a log:
ACCOUNT_CREATED, initialBalance: $0DEPOSIT_MADE, amount: $100WITHDRAWAL_MADE, amount: $50
The current balance ($50) is calculated by replaying these events. This sounds like more work, and it is. But the benefits are immense for complex systems: perfect audit trails, the ability to debug by āreplayingā history to find a bug, and the power to create different āviewsā of the state from the same event log.
Warning: Do not take this path lightly. This is a fundamental architectural shift. It requires a different way of thinking and tooling (like Kafka or a dedicated event store). Itās incredibly powerful for the right problem, but itās not a quick fix for a simple chat app.
Making the Call
So, which one is right for you? I made a quick table to help you decide.
| Solution | Implementation Speed | User Experience | Robustness / Scalability |
|---|---|---|---|
| Optimistic UI | Fast | Very responsive (but can ājumpā) | Low (prone to race conditions) |
| Authoritative Server | Medium (frameworks help) | Good (slight latency on action) | High (the industry standard) |
| Event Sourcing | Slow (major undertaking) | Good (same as authoritative) | Very High (complex but powerful) |
My advice? Start with the Authoritative Server model. Itās the sweet spot of reliability and implementation effort. Look at tools that get you there faster. If your app feels sluggish, you can sprinkle in some Optimistic UI for non-critical actions. And if your app becomes the next Google Docs, well, thatās a good problem to have, and it might be time to read up on Event Sourcing. Now go build something cool, and try not to let the VPs break it.
š Read the original article on TechResolve.blog
ā Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)