Back to Blog
When Your Frontend Needs Real-Time Collaboration Without the WebSocket Headaches
7 min readAug 3, 20264 views

When Your Frontend Needs Real-Time Collaboration Without the WebSocket Headaches

Building real-time collaborative features usually means wrestling with CRDTs or complex state synchronization over WebSockets. Microsoft's Fluid Framework offers a refreshingly opinionated alternative.

Web DevelopmentSoftware DesignDistributed SystemsAPIFrontend
Share

by Sunil Band

Distributed State, Simplified

Building collaborative applications has always been a beast. You start with a simple text editor or a shared canvas, and suddenly you're neck-deep in CRDTs (Conflict-Free Replicated Data Types), operational transformations, custom WebSocket servers, and complex state synchronization logic. It's not just about pushing data around; it's about ensuring eventual consistency, handling network partitions, and resolving concurrent edits gracefully without clobbering user work. Most teams end up building a significant portion of this infrastructure from scratch, which is a massive time sink and a source of subtle, hard-to-debug issues.

Microsoft's Fluid Framework aims to abstract away much of that complexity. It provides a client-side library that lets you define shared data structures, called Distributed Data Structures (DDSs), that automatically synchronize across clients in real-time. Think of it as Google Docs' underlying collaborative engine, but exposed as a set of developer primitives you can drop into any web application. It handles the networking, conflict resolution, and state management, letting you focus on the UI and business logic.

The real power of Fluid isn't just real-time updates; it's the semantic merging it provides. Instead of just overwriting data, DDSs understand the operations being performed (e.g., "insert character at index X," "set value Y to Z") and can intelligently merge changes from multiple clients without losing data. This is a game-changer for building truly resilient collaborative experiences.

The Core Idea: Distributed Data Structures (DDSs)

At its heart, Fluid Framework revolves around DDSs. These aren't just fancy names for objects; they're data structures designed for distributed environments. You instantiate a DDS, attach it to a Fluid container, and from that point on, any changes made to it on one client are automatically propagated and merged across all connected clients. Fluid provides several built-in DDS types, covering common collaboration patterns:

  • SharedMap: A key-value store, much like a JavaScript Map or a plain object, but synchronized. Great for shared settings, user presence, or simple shared state.
  • SharedString: A specialized string data type that handles concurrent text edits. Perfect for collaborative text fields or rich-text editors, similar to how Google Docs works.
  • SharedSequence: A generic ordered list that supports insertions and deletions, maintaining order even with concurrent modifications. Useful for shared lists, arrays of components, or even canvases where elements have a z-index.
  • SharedCounter: A simple counter that can be incremented or decremented by multiple clients without race conditions.

Under the hood, Fluid leverages an operational transformation (OT)-like approach (though they call it sequenced operations), where each change is an operation that gets ordered by a Fluid service before being applied by clients. This ensures a consistent order of operations and enables conflict resolution. The service acts as an ordering server, not a state server – it merely sequences the operations, while clients maintain their own full state.

A Shared Todo List Example

Let's walk through a simple collaborative todo list. We'll use a SharedMap to store the todo items, where each key is a unique ID and the value is an object representing the todo. We'll also use a SharedSequence to maintain the order of todo IDs.

First, you'd set up a basic Fluid client. For local development, Fluid provides a Tinylicious server you can run. In production, you'd use a Fluid service like Azure Fluid Relay or a self-hosted alternative.

typescript
import { TinyliciousClient } from "@fluid-experimental/tinylicious-client";
import { SharedMap } from "@fluidframework/map";
import { SharedSequence } from "@fluidframework/sequence";
import { ContainerSchema, FluidContainer } from "@fluidframework/fluid-static";

// Define our container schema: what DDSs it contains
const containerSchema: ContainerSchema = {
    initialObjects: {
        todos: SharedMap, // Stores actual todo items (id -> { text, completed })
        todoOrder: SharedSequence // Stores ordered list of todo IDs
    },
};

async function getFluidContainer() {
    const client = new TinyliciousClient();
    let container: FluidContainer;
    let containerId: string;

    // Try to get a container from the URL hash, otherwise create a new one
    if (location.hash.length === 0) {
        ({ container } = await client.createContainer(containerSchema));
        containerId = await container.attach();
        location.hash = containerId; // Store container ID in URL for sharing
    } else {
        containerId = location.hash.substring(1);
        ({ container } = await client.getContainer(containerId, containerSchema));
    }

    return container;
}

// Main application logic
async function runApp() {
    const container = await getFluidContainer();

    const todosMap = container.initialObjects.todos as SharedMap;
    const todoOrderSequence = container.initialObjects.todoOrder as SharedSequence;

    // Render function (simplified, imagine a React component)
    const renderTodos = () => {
        const root = document.getElementById('app-root');
        if (!root) return;
        root.innerHTML = ''; // Clear previous render

        const orderedIds = todoOrderSequence.as.</* string[] */any>().getItems(); // Get current order
        orderedIds.forEach(id => {
            const todo = todosMap.get(id);
            if (todo) {
                const div = document.createElement('div');
                div.className = `todo-item ${todo.completed ? 'completed' : ''}`;
                div.innerHTML = `
                    <input type="checkbox" ${todo.completed ? 'checked' : ''} data-id="${id}" />
                    <span>${todo.text}</span>
                    <button data-id="${id}">x</button>
                `;
                root.appendChild(div);

                // Event listeners for toggle and delete
                div.querySelector('input')?.addEventListener('change', (e) => {
                    const target = e.target as HTMLInputElement;
                    todosMap.set(id, { ...todo, completed: target.checked });
                });
                div.querySelector('button')?.addEventListener('click', () => {
                    todoOrderSequence.remove(todoOrderSequence.indexOf(id), 1); // Remove from order
                    todosMap.delete(id); // Delete from map
                });
            }
        });
    };

    // Listen for changes on both DDSs to re-render
    todosMap.on("valueChanged", renderTodos);
    todoOrderSequence.on("sequenceDelta", renderTodos);

    // Initial render
    renderTodos();

    // Add new todo functionality
    document.getElementById('add-todo-button')?.addEventListener('click', () => {
        const input = document.getElementById('new-todo-input') as HTMLInputElement;
        const text = input.value.trim();
        if (text) {
            const id = `todo-${Date.now()}`;
            todosMap.set(id, { text, completed: false });
            todoOrderSequence.insert(todoOrderSequence.length, [id]); // Add to the end of the order
            input.value = '';
        }
    });
}

runApp().catch(console.error);

In this example, todosMap.set, todosMap.delete, todoOrderSequence.insert, and todoOrderSequence.remove are the only lines that modify shared state. All the synchronization, merging, and conflict resolution happens automatically. When another client makes a change, the valueChanged or sequenceDelta events fire, prompting a re-render.

This pattern is incredibly powerful. You interact with the DDSs almost exactly like their non-distributed counterparts, but with the magical property that every mutation is automatically broadcast and intelligently merged across all collaborators. This dramatically reduces the surface area for errors compared to manually managing WebSocket messages and state reconciliation.

Trade-offs and Considerations

Fluid Framework isn't a silver bullet. Like any powerful tool, it comes with its own set of trade-offs:

  1. Complexity Curve: While it simplifies real-time collaboration, understanding the core concepts of DDSs and containers still requires an initial investment. It's a mental model shift from traditional client-server architectures.
  2. Hosting: You need a Fluid service to operate. For development, Tinylicious is great. For production, you'd typically use Azure Fluid Relay (a managed service) or potentially host your own Fluid service, which adds operational overhead.
  3. Data Model Limitations: While flexible, DDSs are opinionated. If your collaborative needs deviate significantly from the patterns they support (e.g., highly custom graph structures with complex merging rules), you might find yourself fighting the framework. However, for most common collaboration scenarios (text, lists, maps, counters), they are excellent.
  4. Bundle Size: Including the Fluid client libraries will add to your frontend bundle size. For small, non-collaborative applications, this might be overkill. But for apps where collaboration is a core feature, the benefits far outweigh this cost.

One common pitfall I see is trying to use DDSs for all application state. It's often better to treat DDSs as your shared collaborative state layer, while keeping transient, UI-specific state (like a modal's open/closed status or form input values before submission) in local component state. This keeps your DDSs focused on the truly shared data and reduces unnecessary network traffic.

Wrapping up

Fluid Framework fundamentally changes how you approach building real-time collaborative features. It moves the burden of distributed state management from your application logic to a robust, battle-tested framework. You get the benefits of concurrent editing and eventual consistency without having to become a CRDT expert.

If you're building a feature that requires multiple users to interact with the same data simultaneously—a shared whiteboard, a collaborative document, a real-time dashboard—I highly recommend giving Fluid Framework a serious look. The best way to get started is to clone their Fluid Framework examples repository and run one of the more complex examples locally with Tinylicious. It really clicks when you see two browsers updating each other instantly and correctly.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕