Designing a Real-Time Collaborative Story Writing Platform
A complete, interview-focused architecture walkthrough for building a system where thousands of writers can co-author the same evolving story or fan-fiction universe — together, live, without stepping on each other’s sentences.
Introduction & History
Imagine a campfire. One person starts a story — “Once upon a time, in a kingdom built on the back of a sleeping dragon…” — and then, instead of finishing it alone, they pass the story to the person next to them. That person adds a line. Then the next person adds a line. The story grows, twists, and surprises everyone, including the people who started it. Now imagine that campfire has ten thousand people sitting around it, all trying to add their line at the exact same second, from phones and laptops scattered across the planet, and the story still has to make sense, stay saved forever, and never lose a single word. That, in essence, is what a real-time collaborative story writing platform is: a system that lets many people write into the same living document at the same time, safely, quickly, and without chaos.
This idea did not start with technology — it started with games. Long before computers, people played “exquisite corpse,” a parlor game invented by surrealist writers in the 1920s, where each person wrote a line of a poem or story, folded the paper to hide it, and passed it on. The internet turned this analog game into something continuous and infinite. Early web forums in the 1990s hosted “round-robin” story threads, where users took turns posting chapters. Then came dedicated fan-fiction communities — Fanfiction.net (1998), and later Archive of Our Own, AO3 (2009) — which let fans of books, shows, and games write their own extended stories set in those universes, though mostly asynchronously (one author writes, others read and comment later, not truly at the same time).
The shift to genuinely real-time collaboration came from a different lineage: collaborative document editing. Google Docs, launched in 2006 (built on the acquired “Writely” and “Google Sheets” technology), proved that multiple people could type into the same document and see each other’s cursors and characters appear instantly. That same real-time engine — originally built for business memos and spreadsheets — is the direct technical ancestor of what we are designing here. Take Google Docs’ real-time sync engine, point it at creative fiction instead of business documents, add chapters, branching storylines, character sheets, comment threads, and a social layer for fandoms, and you get a collaborative storytelling platform: a blend of Google Docs, Wattpad, AO3, and Discord.
Exquisite Corpse
Surrealist parlor game: each writer contributes a line without seeing the whole picture, folding paper to hide prior contributions. The philosophical seed of collaborative, emergent storytelling.
Fanfiction.net
Mass fan-fiction hosting goes online; asynchronous, single-author chapters, community reading and reviewing.
Google Docs (Writely)
Real-time multi-cursor editing enters the mainstream, built on Operational Transformation (OT) — the first production-grade algorithm for resolving simultaneous edits.
Archive of Our Own (AO3)
Fan-run, nonprofit fan-fiction archive; strong tagging and community norms, though writing itself remains largely single-author and asynchronous.
Figma & the rise of CRDTs
Figma popularizes Conflict-free Replicated Data Types (CRDTs) for real-time design collaboration, offering offline-friendly, peer-to-peer-capable sync — an alternative to OT that fits creative, branching work better.
AI-assisted, branching collaborative fiction
Platforms like NovelAI, campfire-style Discord “story games,” and collaborative worldbuilding tools combine real-time editing, versioned branches (“what if this chapter went differently?”), and AI co-writing suggestions.
It forces you to reason about real-time networking (WebSockets), distributed consistency (CRDTs/OT), data modeling for rich text, horizontal scaling of stateful connections, and product-level trade-offs (creative freedom vs. structure) all at once — a genuinely full-stack systems problem, not just “add a cache and a load balancer.”
Problem & Motivation
Before drawing a single box on an architecture diagram, we need to be precise about what problem we are solving and why it’s genuinely hard. Writing a story alone is a solved problem — that’s just a text editor. The difficulty appears the moment you allow more than one person to edit the same passage at the same time.
2.1 The core tension
Two writers, Aisha and Ben, are both looking at the sentence: “The dragon opened one eye.” Aisha starts typing at the end: “…and looked directly at the village.” At the very same moment, Ben places his cursor in the middle and types: “…slowly,” after “eye.” Both edits leave their machine within milliseconds of each other. If the server just applies them in the order they happen to arrive, one person’s intention can silently corrupt the other’s — inserting text at the wrong character position, splitting words, or losing content outright. Multiply this by hundreds of simultaneous editors across a large collaborative fan-fiction “war room,” and naive last-write-wins logic turns a shared story into garbled nonsense within seconds.
2.2 What makes this harder than a typical CRUD app
Concurrent, fine-grained edits
Changes happen at the character level, dozens of times per second per user, not once per “save” click.
Low-latency expectations
Users expect to see each other’s typing within roughly 100 milliseconds — anything slower feels “laggy” and breaks the illusion of togetherness.
Long-lived stateful connections
Unlike a REST API, this system keeps millions of open WebSocket connections alive for hours, which changes how you load balance and scale.
Branching narrative structure
Unlike a business document, a story might legitimately want to fork — “canon” vs. “alternate ending” branches — which is closer to Git than to Google Docs.
Creative ownership & moderation
Attribution (“who wrote this paragraph?”), plagiarism, copyright of fan-fiction content, and abuse/harassment moderation all matter more here than in an internal company wiki.
Offline & flaky mobile networks
Writers often draft on the subway or with poor connectivity; the system must merge their offline edits back in without loss when they reconnect.
2.3 Goals
- Functional: multiple users edit the same story/chapter concurrently with live cursors and presence; changes converge to an identical final document for everyone; version history and rollback; branching for “alternate timelines”; comments, reactions, and chapter-level publishing; rich text and inline media (images, character cards).
- Non-functional: sub-100ms edit propagation within a region; eventual, guaranteed convergence across all replicas (no diverging copies of the story); horizontal scalability to millions of concurrent editing sessions; 99.95%+ availability; durability — a submitted keystroke must never be silently lost; strong abuse/spam resistance given open, public collaboration.
Why not just use a simple “lock the paragraph while someone is editing it” approach instead of a fancy CRDT/OT engine? — Locking optimizes for correctness at the cost of collaboration itself; it turns “real-time co-writing” into “polite queueing,” which kills the product’s core value proposition. It also creates a single point of contention: if a lock holder disconnects mid-edit, everyone else is blocked until a timeout. Real products (Google Docs, Figma) chose optimistic, lock-free concurrency (OT/CRDT) specifically because creative collaboration requires everyone to feel like they can write at once.
Core Concepts You Must Understand First
3.1 Operational Transformation (OT)
What: OT is an algorithm that takes two edits made concurrently (before either has seen the other) and mathematically “transforms” one against the other so both end up producing the same final text, no matter what order they’re applied in.
Imagine two people editing the same paper map with sticky notes. If Aisha’s note says “insert a river at position 12” and Ben’s note (written before he saw Aisha’s) says “insert a mountain at position 10,” a transform function adjusts Aisha’s position from 12 to 13 once Ben’s insertion is accounted for — because Ben’s mountain note shifted everything after position 10 to the right by one. OT is the formal, provably-correct version of that adjustment.
Where used: Google Docs, Etherpad, early collaborative editors. Requires a central server to serialize the order of operations, which makes it simpler to reason about but harder to make peer-to-peer or fully offline-tolerant.
3.2 Conflict-Free Replicated Data Types (CRDTs)
What: A CRDT is a data structure specially designed so that if two or more copies of it are modified independently (even offline, even in different order) and then merged, they mathematically always converge to the same final state — with no central coordinator required, and no possibility of conflict.
Think of a shared shopping list where every item is tagged with “who added it” and “when.” If two people add different items while offline, merging the lists later is trivial — just union everything together with tie-breaking rules for order. That’s roughly how a text CRDT works: every character you type gets a unique, permanent identifier (not just a position number), so merging two divergent copies of the document is a deterministic, lock-free operation.
Where used: Figma, Notion (partially), Automerge, Yjs — most modern collaborative editors have shifted toward CRDTs because they tolerate offline editing and peer-to-peer sync far better than OT.
3.3 Why this system leans CRDT over OT
Fan-fiction and story writing has two properties that favor CRDTs specifically: writers frequently go offline (drafting on a plane, a subway, a phone with spotty signal) and then reconnect with a burst of local edits to merge; and the product wants branching (alternate story timelines), which is naturally modeled as forking and later merging replicas of a CRDT document — very similar to how Git forks and merges branches of code.
3.4 Presence & Awareness
What: Beyond the text itself, users need to see where other people are — colored cursors, selection highlights, “Ben is typing…” indicators, and avatars in the margin. This is called “awareness” state, and it is deliberately treated as ephemeral (not persisted to the database, not part of the CRDT), because losing a cursor position on reconnect is harmless, unlike losing actual story text.
3.5 CAP Theorem in this context
The CAP theorem says a distributed system can only fully guarantee two of Consistency, Availability, and Partition tolerance at once. For collaborative editing, we deliberately choose AP (Availability + Partition tolerance) with eventual consistency: every writer should always be able to keep typing, even during a network partition, and all replicas will converge once the partition heals. We never want a writer’s keystrokes to be rejected because “the system can’t guarantee strict consistency right now” — that would destroy the writing experience. CRDTs are, in fact, a purpose-built tool for building AP systems with mathematically guaranteed eventual convergence.
CRDT vs OT — which would you pick and why? — Both converge correctly, but CRDTs shine when you need offline editing, peer-to-peer or multi-region merge without a single serialization point, and branching/forking semantics — all of which fit a creative writing platform. OT shines in tightly centralized, always-online systems (like enterprise document editors) where a single server can serialize operation order cheaply, and where the smaller memory footprint of OT (versus the extra per-character metadata CRDTs store) matters. For this problem, I’d choose a CRDT (e.g., an architecture similar to Yjs or Automerge).
3.6 Choosing the transport protocol: WebSocket vs. SSE vs. long polling
Real-time systems have three common transport choices, and interviewers often want to hear you reason through all three rather than jumping straight to “WebSocket” by memory.
| Protocol | Direction | Overhead | Fit for this system |
|---|---|---|---|
| Long polling | Client repeatedly asks server “anything new?” | High — new HTTP request/response per poll, extra latency waiting for the next poll cycle | Poor — too slow and wasteful for character-level, sub-100ms updates |
| Server-Sent Events (SSE) | Server → client only, one-way | Low, built on plain HTTP, easy to proxy/cache-bust | Half-fit — great for one-way notification-style updates, but writers also need to send edits, so a second channel would be required anyway |
| WebSocket | Full-duplex, client ↔ server | Low once connection is established; slightly higher setup cost (handshake, upgrade) | Best fit — a single persistent bidirectional channel matches exactly how collaborative editing needs to flow: edits going both up and down continuously |
We settle on WebSocket for the live-editing channel specifically because it is bidirectional and low-overhead per message once established, which matters enormously when a single active writer might generate dozens of tiny operations per second. SSE remains useful as a secondary, simpler channel for one-way notifications (e.g., “a comment was posted”) where a full duplex socket would be overkill.
3.7 Data structure choice for the text itself: array, rope, or tree?
Beneath the CRDT layer sits a more basic question: what in-memory data structure actually stores the characters of a chapter that might grow to 50,000+ words? A naive contiguous character array makes insertion in the middle an $O(n)$ operation — copying every character after the insertion point — which becomes painfully slow for a long chapter edited thousands of times. Three realistic choices:
| Structure | Insert/Delete | Random access | Notes |
|---|---|---|---|
| Flat array/string | $O(n)$ | $O(1)$ | Simple, but insertion cost grows with document length — unacceptable at scale for long chapters |
| Rope (balanced binary tree of chunks) | $O(log n)$ | $O(log n)$ | Classic text-editor structure (used in editors like VS Code); good general-purpose balance |
| RGA linked structure (our choice) | $O(1)$ amortized append, $O(log n)$ with an index for random lookup | $O(log n)$ with a supporting skip-index | Naturally matches the CRDT’s per-character identity model; we pair it with a secondary balanced index to avoid $O(n)$ traversal for cursor-position lookups |
In production, the RGA linked structure is paired with an auxiliary balanced-tree index (mapping visible character offsets to node references), so that “insert at cursor position 4,532” doesn’t require walking 4,532 linked-list nodes one at a time — it resolves in logarithmic time, keeping typing responsive even in very long chapters.
3.8 Consensus & leader election for sharded actors
Although CRDT merge itself needs no consensus (that’s the whole point), the infrastructure around it still needs a way to agree on which node currently owns which story’s actor, so that two nodes don’t both believe they’re authoritative for the same shard during a rebalance or failover. We use a lightweight consensus layer (e.g., etcd or ZooKeeper, built on the Raft consensus algorithm) purely for this ownership/leader-election bookkeeping — a small, well-understood problem — while keeping the actual document merge logic consensus-free and highly available. This separation is deliberate: we don’t want the availability of the whole writing experience to depend on a consensus quorum being reachable; we only want shard ownership decisions to go through consensus, and even a brief unavailability there just delays a rebalance, it doesn’t block anyone from typing.
If CRDTs don’t need consensus, why does your architecture use Raft/ZooKeeper at all? — Consensus-free applies to merging document content — that’s the CRDT’s job. But the surrounding cluster still needs to agree on operational facts like “which physical node currently owns shard 42” during scaling or failover, and that’s a classic leader-election problem where consensus algorithms are the right, well-proven tool. Keeping these concerns separate means a brief consensus-layer hiccup only delays rebalancing decisions — it never blocks a writer’s keystroke, since the CRDT merge path doesn’t depend on it.
Architecture & Components
Below is the high-level architecture. Every box represents a distinct, independently scalable component. Notice the split between the synchronous request path (REST calls through the API Gateway for things like login, browsing stories, publishing chapters) and the real-time path (persistent WebSocket connections through a dedicated Realtime Gateway for live character-by-character editing).
React with CRDT client lib”] MOB[“Mobile App
iOS Android”] end CDN[“CDN Edge Cache
static assets published chapters images”] DNS[“DNS and GeoDNS Routing
routes user to nearest region”] subgraph EDGE[“Edge Entry Layer”] LB[“Load Balancer
L4 L7 TLS health checks”] GW[“API Gateway
authN rate limiting routing”] WSGW[“Realtime WebSocket Gateway
connection mgmt pubsub bridge”] end subgraph CORE[“Core Services (stateless autoscaled)”] AUTH[“Auth Service
OAuth2 JWT sessions”] STORY[“Story Chapter Service
CRUD metadata structure branches”] COLLAB[“Collaboration Engine
CRDT actor per story”] PRES[“Presence Service
cursors typing indicators”] MOD[“Moderation Service
content filtering abuse”] NOTIF[“Notification Service
push email in app”] SEARCH[“Search Service
story tag author search”] end subgraph MSG[“Messaging Backbone”] BROKER[“Message Broker
Kafka durable event log”] PUBSUB[“Pub Sub Layer
Redis Streams fan out live edits”] end subgraph DATA[“Data Layer”] DOCDB[“Document Store
MongoDB CRDT snapshots sharded”] RDB[“Relational DB
PostgreSQL users metadata permissions”] CACHE[“Cache
Redis hot docs sessions presence”] ES[“Search Index
Elasticsearch”] OBJ[“Object Storage
S3 images ebooks backups”] end MON[“Monitoring and Observability
Prometheus Grafana Jaeger ELK”] WEB –> DNS MOB –> DNS DNS –> CDN DNS –> LB CDN –> WEB LB –> GW LB –> WSGW GW –> AUTH GW –> STORY GW –> MOD GW –> SEARCH WSGW –> COLLAB WSGW –> PRES COLLAB –> PUBSUB PRES –> PUBSUB COLLAB –> BROKER BROKER –> STORY BROKER –> NOTIF BROKER –> MOD BROKER –> ES STORY –> RDB STORY –> DOCDB COLLAB –> DOCDB COLLAB –> CACHE PRES –> CACHE AUTH –> RDB AUTH –> CACHE SEARCH –> ES STORY –> OBJ CORE -.->|”metrics traces logs”| MON
4.1 Component-by-component breakdown
Load Balancer
Sits directly behind DNS/GeoDNS and terminates TLS. For synchronous HTTP it can be a standard round-robin/least-connections L7 balancer. For WebSocket traffic it must support long-lived upgrades and ideally avoid sticky sessions — we push affinity down into the pub/sub layer so any gateway node can serve any client.
API Gateway
The single entry point for all synchronous REST/GraphQL calls: login, fetching a story’s metadata, listing chapters, submitting a comment, publishing a chapter. Handles authentication (validating JWTs), coarse-grained rate limiting per user/IP, request validation, and routes to the correct downstream microservice. Stateless and horizontally scalable.
Realtime WebSocket Gateway
A separate fleet from the API Gateway, purpose-built to hold millions of long-lived WebSocket connections. Each node is a thin router: authenticates once at handshake, then forwards ops to the Collaboration Engine via Pub/Sub, and streams responses back. Holds no authoritative document state.
Collaboration Engine (CRDT Actor)
The heart of the system. For every actively-edited story/chapter, one logical “document actor” holds the authoritative in-memory CRDT state, applies incoming operations, and broadcasts merged operations back out. Sharded by story_id.
Presence Service
Tracks ephemeral state: who’s connected, cursor positions, current selection, “typing…” indicators. Backed purely by an in-memory cache (Redis) with short TTLs — disposable data, kept out of the durable path entirely.
Story / Chapter Service
Owns structural metadata: story titles, authors, chapter ordering, branch/fork relationships, tags, ratings, publishing state. A conventional CRUD service backed by a relational database.
Moderation Service
Consumes a stream of newly-published content (and sampled in-progress edits) to run automated checks: spam detection, harassment/hate-speech filtering, copyright/plagiarism screening, NSFW/age-rating classification, escalating to human moderators when confidence is low.
Notification Service
Consumes events (someone commented on your chapter, a co-author published, your branch got merged) from the message broker and fans them out via push notifications, email, and in-app alerts.
Search Service
Indexes published stories, tags, authors, and chapter text into a full-text search index (Elasticsearch), updated asynchronously from the message broker whenever content is published or edited.
Message Broker (Kafka)
The durable backbone connecting every service. All significant events flow through Kafka topics partitioned by story_id, giving us replayability, decoupling, and audit history.
Pub/Sub Layer (Redis Streams)
A lower-latency, ephemeral fan-out layer specifically for live keystroke-level operations and presence updates — too high-frequency and disposable for Kafka’s durability overhead, so it’s split into its own fast path.
Document Store (sharded MongoDB)
Stores periodic CRDT snapshots and the append-only operation log for each story, sharded by story_id so a single popular story’s data and load is spread across shard replicas, while unrelated stories don’t compete for the same shard’s resources.
Relational Database (PostgreSQL)
Stores users, permissions/roles, story and chapter metadata, comments, and billing — anything with strong relational integrity requirements and ACID transaction needs.
Cache (Redis)
Caches hot/actively-edited documents in memory (avoiding MongoDB on every keystroke), session tokens, and presence data.
Object Storage (S3)
Stores embedded images, character portrait uploads, exported ebook files (EPUB/PDF), and long-term cold backups of document history.
Monitoring & Observability
A cross-cutting layer (Prometheus metrics, Grafana dashboards, distributed tracing via Jaeger/OpenTelemetry, centralized logs via the ELK stack) that every service reports into.
Why split the WebSocket Gateway from the API Gateway instead of using one gateway for everything? — They have fundamentally different scaling and failure characteristics. The API Gateway handles short-lived, stateless request/response traffic and scales purely on CPU/request-rate. The WebSocket Gateway must hold millions of persistent open connections in memory, so it scales on connection-count and memory, needs different autoscaling policies, and a crash there has a very different blast radius (dropped live sessions vs. a failed API call that a client simply retries). Separating them lets each be tuned, deployed, and scaled independently.
Internal Working: How the CRDT Engine Actually Merges Edits
This is usually the section that separates a strong system design answer from a great one. Let’s walk through exactly how a keystroke travels from Aisha’s keyboard to Ben’s screen and back, and why the merged result is guaranteed to be identical for everyone.
5.1 The RGA (Replicated Growable Array) approach
A popular, interview-friendly CRDT structure for text is the Replicated Growable Array (RGA). Instead of storing text as “characters at positions” (position 0, 1, 2…), each character is given a permanent, globally unique ID made of (user_id, logical_clock), and a pointer to the ID of the character it was inserted after. This turns the document into a linked list of immutable, uniquely-identified nodes rather than a mutable array — insertion never has to “renumber” anything, which is exactly what makes concurrent inserts conflict-free.
Instead of numbering chairs 1, 2, 3… (which breaks the moment you squeeze a new chair between two others), you say “my chair goes right after Priya’s chair.” Everyone’s chair position is defined relative to another chair’s unique ID, not an absolute number. Two people can simultaneously say “I’ll sit after Priya” and a simple, agreed-upon tie-breaking rule (e.g., compare user IDs) decides who ends up on the left vs. right — no renumbering, no conflict.
5.2 Step-by-step: two concurrent inserts
- Aisha’s client has local text “…eye.” and types “d” — creating a new node
{id:(aisha,102), after:(dot_char_id), value:'d'}, applies it optimistically to her own local view instantly (this is what makes typing feel instant), and sends it to the Collaboration Engine over the WebSocket. - Ben’s client, unaware of Aisha’s edit, types “ slowly” after “eye” at nearly the same moment — creating its own chain of nodes anchored to the same “eye” node.
- Both operations arrive at the Collaboration Engine’s document actor (possibly out of order, since network latency varies). The actor applies each operation to its authoritative CRDT state using the RGA insertion rule: “insert this node immediately after the node with this ID; if another node is already there, use the tie-break rule (compare origin user IDs) to decide left/right ordering.”
- Because the merge rule is commutative and deterministic — the same final order results no matter which operation is applied first — the actor’s resulting state is well-defined and identical regardless of arrival order.
- The actor broadcasts both operations (not the full document — just the deltas) to every other connected client via the Pub/Sub layer, including back to Aisha and Ben themselves for acknowledgment.
- Every client applies the same two operations using the same deterministic merge rule, so all replicas — server and every browser tab — converge to the exact same final text, even though Aisha and Ben never saw each other’s edit before typing.
public class CollaborationEngine {
// Each node: unique id, the id it was inserted after, its character, and a tombstone flag for deletes
private final Map<OpId, RgaNode> nodes = new ConcurrentHashMap<>();
private final OpId HEAD = OpId.sentinel();
// Applies an insert operation; safe to call in any arrival order
public synchronized void applyInsert(InsertOp op) {
RgaNode newNode = new RgaNode(op.id(), op.afterId(), op.value());
RgaNode anchor = nodes.getOrDefault(op.afterId(), RgaNode.head(HEAD));
// Tie-break concurrent inserts at the same anchor by comparing (timestamp, userId)
RgaNode sibling = anchor.firstChild();
while (sibling != null && sibling.id().compareTo(op.id()) > 0) {
anchor = sibling;
sibling = sibling.nextSibling();
}
anchor.insertChildAfter(newNode);
nodes.put(op.id(), newNode);
}
// Deletes are tombstones, never physical removals - required for correct concurrent merge
public synchronized void applyDelete(DeleteOp op) {
RgaNode target = nodes.get(op.targetId());
if (target != null) target.markTombstoned();
}
// Renders the current visible text by walking the linked structure, skipping tombstones
public String renderText() {
StringBuilder sb = new StringBuilder();
for (RgaNode n : traverseInOrder(HEAD)) {
if (!n.isTombstoned()) sb.append(n.value());
}
return sb.toString();
}
}
5.3 Deletes: tombstones, not removals
A critical, often-missed detail: when text is deleted, the CRDT does not physically remove the node. It marks it as a tombstone (invisible, but still present in the structure). This matters because another client, offline at the time of the delete, might still send an operation anchored to that “deleted” character’s ID — if the node were truly gone, that anchor reference would be broken. Tombstones are periodically garbage-collected only once we’re certain no offline client can still reference them (typically after a safe retention window, e.g. 30 days).
5.3.1 Why this level of detail matters
It’s tempting to treat “we use a CRDT” as a sufficient answer and move on, but the specific mechanics — unique per-character identity, anchor-based insertion, tombstoned deletes, deterministic tie-breaking — are exactly what an interviewer is probing for when they ask a follow-up like “walk me through what actually happens when two people type at the same position.” Being able to describe the RGA structure concretely, rather than gesturing vaguely at “some conflict-free algorithm,” is usually the difference between a design that sounds correct and one that’s been genuinely reasoned through.
5.4 Snapshotting & the operation log
Replaying every single character-insert operation from the beginning of a long-running story would be slow. So the Collaboration Engine periodically (e.g., every 200 operations or 60 seconds) writes a compacted binary snapshot of the current CRDT state to the Document Store, alongside a trimmed operation log of only the ops since that snapshot. Reconnecting clients (or a freshly-spun-up actor after a crash) load the latest snapshot plus replay the small remaining log — turning an $O(text{all history})$ load into an $O(text{recent ops})$ load.
5.5 Concurrency & thread safety within a single actor
Even though CRDT merges are conflict-free at the data-structure level, the Collaboration Engine process itself still needs to handle many simultaneous incoming operations for the same story without corrupting its in-memory structures — a classic concurrent-programming problem, separate from the distributed-merge problem. Two practical approaches: (1) the actor-per-document model, where each document’s operations are processed strictly sequentially by a single dedicated worker thread or event loop (via an internal queue), sidestepping the need for fine-grained locking entirely — simpler to reason about, at the cost of pinning one document’s throughput to a single thread’s speed; or (2) fine-grained locking/synchronization (as shown in the earlier Java example’s synchronized methods) allowing multiple threads to process different documents’ operations in parallel within one process, while still serializing access to any single document’s shared state. In production, most systems favor the actor-per-document model for its simplicity and predictability, reserving thread-pool parallelism for spreading many different documents’ single-threaded actors across available cores, rather than trying to parallelize a single document’s operation stream.
This matters for an interview answer because it shows you understand that “conflict-free” at the CRDT-algorithm level does not automatically mean “thread-safe” at the implementation level — those are two different concurrency problems solved by two different techniques, and conflating them is a common gap in weaker answers.
How do you keep memory bounded when a CRDT keeps every deleted character as a tombstone forever? — Track the minimum “last synced” logical clock across all currently-connected and recently-active replicas (including offline-but-recently-seen mobile clients). Any tombstone older than that watermark is provably safe to garbage-collect, since no live replica could still reference it. Combine this with periodic snapshotting so garbage collection only has to run against a bounded recent window, not the entire history.
Data Flow & Lifecycle
Let’s trace a chapter from “blank page” to “published, searchable story” end to end.
Session start
User authenticates via the API Gateway (JWT issued by Auth Service), then opens a chapter, which upgrades to a WebSocket connection through the Realtime Gateway. The gateway subscribes the connection to that chapter’s Pub/Sub channel and requests the latest snapshot from the Collaboration Engine.
Live editing
Every keystroke becomes a small CRDT operation, applied optimistically on the client, sent to the Collaboration Engine, merged, and fanned back out to all co-editors within tens of milliseconds. Presence updates (cursor position) flow on a parallel, lower-priority channel.
Durable persistence
Operations are asynchronously appended to the durable operation log (Kafka + Document Store) so that even a Collaboration Engine crash loses at most a sub-second window of unacknowledged ops, not the whole session.
Snapshotting
Periodic compaction writes a fresh binary snapshot, bounding recovery time and storage growth.
Publishing
When an author marks a chapter “published,” the Story Service freezes a read-only copy, triggers Moderation Service review, and emits a “chapter.published” event to Kafka.
Fan-out
The Notification Service alerts subscribers/co-authors; the Search Service indexes the new text; the CDN caches the published, rendered chapter for fast public reads.
Branching (optional)
A reader/writer can “fork” a published chapter into an alternate-timeline branch — this clones the CRDT state as a new document actor with a parent-pointer back to the original, enabling independent, isolated future editing.
Treating “publish” as just a boolean flag on the live, still-editable CRDT document. In production you want publishing to snapshot an immutable copy for readers and search indexing, while the live document can keep evolving in a “draft” state — otherwise a typo fix made after publishing would silently and un-audibly rewrite what thousands of readers already read and quoted.
6.1 Branching and version control, in more depth
Branching deserves special attention because it’s the feature that most distinguishes this system from a plain collaborative editor like Google Docs. When a writer forks a chapter into an alternate timeline, the platform doesn’t copy the rendered text as a dumb string snapshot — it clones the underlying CRDT structure itself, including every character’s unique identity and causal history up to the fork point. This has a genuinely useful consequence: because both the original and the forked branch share the same character-identity scheme up to the divergence point, it’s technically possible (and a compelling advanced feature) to later compute a structural diff between two branches, or even offer a “cherry-pick this paragraph from the alternate branch back into canon” merge operation, using much of the same conflict-free merge machinery that powers everyday concurrent editing. This is analogous to how Git branches share commit history up to a fork point, enabling diffing and selective merging long after the branches have diverged.
Version history more broadly is simply a query over the same durable, ordered operation log used for crash recovery — “what did this chapter look like at 3:00pm yesterday” is answered by replaying the nearest snapshot before that timestamp plus the subsequent operations up to it. Because this reuses infrastructure we already need for reliability (Section 9) and internal recovery (Section 5.4), version history is close to “free” from an infrastructure standpoint once the event-sourced foundation is in place — a good example of how a sound core architectural decision pays for a valuable product feature almost incidentally.
Advantages, Disadvantages & Trade-offs
Advantages
- Lock-free, always-available editing — no writer is ever blocked
- Mathematically guaranteed convergence, even across offline edits
- Natural support for branching/forking narratives
- Gateway and Collaboration layers scale independently and horizontally
- Durable operation log doubles as a full, replayable version history
Disadvantages / Costs
- CRDT metadata (per-character IDs) can be 3-10× the size of raw text
- Tombstone accumulation requires careful, ongoing garbage collection
- Debugging “why did the merged text look like that” is harder than linear logs
- Eventual (not immediate) consistency can very briefly show diverging views under high latency
- Significantly more engineering complexity than a simple “save on submit” text field
7.1 Key trade-off table
| Decision | Option A | Option B | Choice & reason |
|---|---|---|---|
| Conflict resolution | Operational Transformation | CRDT | CRDT — better offline & branching support |
| Consistency model | Strong consistency | Eventual consistency (AP) | Eventual — never block a writer’s keystroke |
| Document DB | Single large relational table | Sharded document store | Sharded — isolates hot stories, scales writes |
| Presence data | Persisted to DB | Ephemeral, in-memory only | Ephemeral — disposable, avoids write amplification |
| Delete semantics | Physical delete | Tombstone + GC | Tombstone — required for correct concurrent merge |
None of these trade-offs are free wins — each column in the table above represents a real cost accepted in exchange for a real benefit, and a mature design discussion should acknowledge both sides rather than presenting the chosen option as strictly superior in every dimension. The common thread across all five decisions is a consistent bias toward availability and collaborative continuity over strict correctness guarantees or storage efficiency — a bias that is the right one for a creative, social writing product, but would be the wrong one for, say, a financial ledger system where strict consistency is non-negotiable. Good system design is rarely about finding a universally “correct” answer; it’s about picking the trade-offs that best fit the specific product’s priorities, and being able to articulate why.
Performance & Scalability
8.1 Sharding the Collaboration Engine
We shard document actors by story_id (or chapter_id for very large multi-author stories) using consistent hashing across a fleet of Collaboration Engine nodes. This means: a wildly popular collaborative story with thousands of simultaneous editors gets its actor(s) pinned to well-provisioned nodes, while millions of quiet, single-author stories share nodes cheaply — true multi-tenant efficiency.
8.2 Scaling WebSocket connections
A single well-tuned gateway node can typically hold 50,000-100,000 concurrent idle-ish WebSocket connections (bounded mostly by memory per connection and file descriptor limits, tuned via OS-level settings). We scale this fleet horizontally behind the load balancer, and because gateway nodes are stateless routers (state lives in the Collaboration Engine / Pub-Sub layer), we can autoscale on connection-count metrics without any handoff complexity.
8.3 Why “sticky sessions” are avoided
A naive design sticks a WebSocket client to one gateway node and requires that same node to hold document state — this creates hot-node problems (a viral story’s editors all pin to one node) and painful failover (losing that node loses live document state). Instead, gateway nodes are dumb routers; actual document state lives in the Collaboration Engine, addressed by story_id via consistent hashing, and reachable from any gateway node through the Pub/Sub layer. This decouples “which gateway a client is connected to” from “which node owns the document,” so both scale independently.
8.4 Batching & back-pressure
To avoid flooding the network with one message per keystroke, clients debounce/batch tiny operations (e.g., 20-50ms windows) before sending, and the Collaboration Engine applies back-pressure (bounded queues with drop-oldest-presence-update policies, never drop-oldest-edit) if a downstream consumer falls behind.
8.5 Back-of-the-envelope capacity estimation
A thorough interview answer usually includes rough numbers, even approximate ones, to show the design was sized rather than guessed. Let’s estimate for a platform with 20 million monthly active writers.
| Assumption | Rough value |
|---|---|
| Monthly active writers | 20,000,000 |
| Concurrent active editing sessions at peak ($approx$2% of MAU online & actively typing) | ~400,000 |
| Average operations per active writer per second | ~2 (typing cadence, after client-side batching) |
| Peak inbound operations/sec across the system | ~800,000 ops/sec |
| Average operation size (character + metadata, compact binary encoding) | ~40 bytes |
| Peak inbound bandwidth for operations | ~800,000 × 40B ≈ 32 MB/s (~256 Mbps) |
| Fan-out multiplier (avg co-editors per active document) | ~3 |
| Peak outbound bandwidth (fan-out) | ~96 MB/s (~768 Mbps) |
| WebSocket Gateway nodes needed (100K connections/node) | ~4 nodes for connection capacity alone, scaled further for CPU/bandwidth headroom (realistically tens of nodes) |
| Collaboration Engine shards (assuming ~5,000 active documents/node comfortably) | ~400,000 concurrent sessions ÷ typical multi-editor grouping → tens of engine nodes, autoscaled by ops/sec per shard rather than raw session count |
These numbers exist to sanity-check design decisions, not to be memorized precisely: they confirm, for example, that bandwidth is comfortably within reach of standard networking (hundreds of Mbps, not many Gbps), that WebSocket connection count — not raw CPU — is the more binding constraint on gateway sizing, and that the Collaboration Engine’s real scaling driver is operations-per-second-per-shard rather than a simple connection count, which is exactly why we shard and autoscale on that custom metric rather than on generic CPU utilization.
8.6 Storage growth estimation
If the average published story reaches 20,000 words (~120,000 characters) and each character’s CRDT metadata adds roughly 24 bytes of overhead (unique ID, origin pointer, tombstone flag) on top of the 1-byte character itself, a single story’s raw CRDT representation could reach roughly 3 MB before compaction. With snapshotting and tombstone garbage collection, steady-state storage per completed story typically compresses down to a few hundred KB — still meaningfully larger than plain text, which is the direct, expected cost of CRDT metadata discussed in the trade-offs section, and a key reason snapshot compaction isn’t optional at scale.
One story goes viral and gets 50,000 simultaneous editors. How does your design handle that hot spot? — Split the hot story’s document into independently-editable sub-regions (e.g., per-chapter or per-scene actors) so no single actor is a bottleneck; scale up dedicated Collaboration Engine capacity for that shard via consistent-hashing rebalancing; and apply UX-level throttling (e.g., “chapter is locked to the first N active co-authors, others join a ‘suggestion’ mode”) as a pragmatic ceiling, since infinite simultaneous fine-grained editors on one paragraph has diminishing product value anyway.
High Availability & Reliability
9.1 No single point of failure
Every stateless tier (API Gateway, WebSocket Gateway, Story Service, Moderation, Notification, Search) runs as a multi-node, auto-healing fleet behind the load balancer. The Collaboration Engine, while “stateful” per document, replicates each active document actor’s in-memory state to at least one hot standby via the durable operation log, so a node failure triggers fast failover (replay the last snapshot + recent ops on a new node) rather than data loss.
9.2 Multi-region & disaster recovery
The system runs active-active across multiple regions for the stateless tiers, with GeoDNS routing writers to their nearest region for latency. The Document Store and Kafka use cross-region replication (async, eventually consistent) so a full regional outage can be failed over to a secondary region with a bounded (typically single-digit-second) recovery point objective. Because CRDTs merge cleanly even across a region failover’s small window of divergence, this is one of the rare architectures where cross-region active-active writing is genuinely tractable.
9.3 Graceful degradation
- If the Moderation Service is degraded, allow publishing to proceed with async, delayed moderation rather than blocking all writers.
- If Search is down, story browsing falls back to cached “popular/recent” lists instead of failing entirely.
- If the Pub/Sub layer has a brief hiccup, clients buffer local operations and replay them once reconnected — nothing is lost because of client-side optimistic application plus durable server-side logging.
9.4 Backups
Document snapshots and operation logs are backed up to Object Storage on a rolling schedule, with point-in-time recovery supported by replaying the operation log up to any target timestamp — which doubles conveniently as the “story version history / time travel” product feature.
9.5 Chaos engineering
Given how much this system’s reliability story depends on graceful recovery rather than avoiding failure entirely, regular chaos experiments are essential rather than optional polish. Representative experiments: randomly killing Collaboration Engine nodes mid-session to verify actor failover and snapshot recovery actually work under real load (not just in unit tests); injecting artificial network partition between regions to confirm CRDT convergence behaves correctly once the partition heals; and throttling the Pub/Sub layer to verify client-side buffering and reconnect logic degrade gracefully rather than dropping edits. Running these experiments regularly in a controlled way, rather than discovering the failure mode for the first time during a real incident, is what actually earns the 99.95% availability target rather than just aspiring to it on paper.
9.6 Disaster recovery runbook (summary)
- Detect: automated alerting fires on regional health-check failures or a sustained spike in client reconnect/error rates.
- Contain: GeoDNS and load balancer health checks automatically stop routing new connections to the impaired region within seconds.
- Fail over: traffic for affected users is redirected to the nearest healthy region; Collaboration Engine actors for affected shards are recreated there from the latest replicated snapshot plus the replicated operation log tail.
- Reconcile: once the impaired region recovers, any operations that occurred only in the failover region during the outage window are merged back via the same CRDT convergence guarantees used for everyday concurrent editing — disaster recovery reconciliation is, elegantly, just another instance of the same merge algorithm the system already relies on daily.
- Review: a blameless post-incident review captures root cause and follow-up action items, feeding back into chaos engineering scenarios to catch the same class of failure earlier next time.
What’s your RPO/RTO for this system, and how do you achieve it? — Target RPO (recovery point objective) of a few seconds — achieved because every operation is durably logged to Kafka near-synchronously before being considered “committed,” not just held in the Collaboration Engine’s memory. Target RTO (recovery time objective) of under a minute for a single node failure — achieved via fast snapshot-based actor recovery — and a few minutes for a full regional failover, bounded by cross-region replication lag.
Security
10.1 Authentication & authorization
OAuth2/OIDC for login (supporting social logins, common for fan-fiction communities), short-lived JWT access tokens plus refresh tokens, validated at the API Gateway and again at WebSocket handshake time. Per-story permissions (owner, co-author, editor, viewer) are enforced both at the REST layer and inside the Collaboration Engine — a viewer’s client should never even receive write-capable operation channels.
10.2 Content-layer risks specific to this system
Stored XSS via rich text
Story content often supports formatting/HTML-like markup; all rendered output must be sanitized (allow-list of safe tags/attributes) both server-side and client-side before rendering to any reader.
Malicious CRDT operations
A compromised or malicious client could send crafted operations (e.g., referencing invalid anchor IDs) to try to corrupt the document; the Collaboration Engine validates and rejects malformed ops rather than trusting client input.
Abuse of open collaboration
Public, joinable “co-write” sessions invite spam and vandalism; rate-limit writes per user per document and support fast rollback via the operation log.
Copyright & fan-fiction legal nuance
Fan-fiction inherently touches copyrighted source material; the platform needs clear terms of service, DMCA takedown workflows, and content attribution tracking rather than trying to solve copyright law technically.
What ties these four risks together is that they’re all consequences of the same product decision — opening up real-time, low-friction, mass collaborative writing to the public — that makes this system valuable in the first place. A design that eliminated all of these risks by, say, requiring heavy manual review before any text became visible to co-authors would also eliminate the real-time collaborative experience that is the entire point of the product. The security posture here is therefore necessarily about layered, mostly-automated mitigation with fast human escalation paths, not prevention that would compromise the core product.
10.3 Rate limiting algorithm choice
Not all rate limiters behave the same under bursty typing patterns, which matters here more than in a typical API. A fixed-window counter (e.g., “100 ops per user per 10-second window”) is simple but allows a burst of 200 ops right at a window boundary (100 at the end of one window, 100 at the start of the next). A sliding-window log or token-bucket algorithm is preferable: a token bucket refills at a steady rate (matching natural typing cadence) but allows short legitimate bursts (pasting a paragraph) up to the bucket’s capacity, then throttles sustained abuse smoothly rather than with hard cliffs. We apply token-bucket limiting per WebSocket connection for operation volume, and a separate, coarser fixed-window limiter at the API Gateway for REST calls like publishing or comment posting.
10.4 Data privacy & regulatory considerations
Because stories and their full edit history are retained (for version control and abuse-reversion purposes), the system must support privacy regulations like GDPR’s “right to erasure.” This is handled by separating authorship identity from content: when a user requests account deletion, their personal identity data (email, real name, login credentials) is purged from PostgreSQL, while their historical CRDT operations are retained but re-associated with an anonymized author token — preserving story integrity and other co-authors’ work without retaining the deleted user’s personal data. This distinction (delete identity, anonymize authorship, preserve content integrity) is a common and defensible pattern for collaborative platforms with legal retention tensions.
10.5 Transport & infrastructure security
- TLS everywhere, including for WebSocket connections (WSS), terminated at the load balancer with modern cipher suites.
- Rate limiting at the API Gateway (per-IP and per-user) and a separate, tighter rate limit on write-operation volume per WebSocket connection to blunt automated abuse or bugs causing operation storms.
- Secrets (DB credentials, signing keys) managed via a secrets manager (e.g., Vault/KMS), never hardcoded or logged.
- Principle of least privilege for service-to-service auth (mTLS or signed service tokens between internal microservices).
A malicious user’s client sends thousands of fake CRDT operations per second to vandalize a story. How do you defend against that? — Layer defenses: (1) per-connection rate limiting at the WebSocket Gateway, disconnecting/throttling clients that exceed a reasonable ops/sec ceiling; (2) server-side validation in the Collaboration Engine rejecting operations with invalid anchors or malformed payloads rather than blindly merging them; (3) since the operation log is durable and replayable, a detected vandalism burst can be surgically reverted by replaying history up to the last good state, then re-applying only legitimate later ops; (4) anomaly detection on write velocity per user feeding into the Moderation Service for account-level action.
Monitoring, Logging & Metrics
11.1 What to measure
| Category | Key metrics | Tooling |
|---|---|---|
| Realtime health | Edit propagation latency (P50/P95/P99), WebSocket connection count, reconnect rate | Prometheus + Grafana |
| Collaboration Engine | Ops/sec per shard, merge conflicts encountered, snapshot duration, actor memory size | Custom metrics + Prometheus |
| Data layer | DB replication lag, cache hit ratio, shard hotspot detection | Prometheus exporters |
| Distributed tracing | End-to-end trace of a keystroke: client → gateway → engine → pub/sub → other clients | OpenTelemetry + Jaeger |
| Logs | Structured, correlation-ID-tagged logs across every service | ELK stack (Elasticsearch, Logstash, Kibana) |
| Business metrics | Active co-writing sessions, chapters published/day, moderation queue depth | Custom dashboards |
11.2 Alerting philosophy
Alert on symptoms users would notice (rising P99 edit latency, WebSocket reconnect storms, snapshot failures) rather than only on low-level resource metrics — a CPU spike that doesn’t affect latency shouldn’t page anyone at 3am, but a latency regression should.
11.3 Distributed tracing example
Every operation carries a trace ID generated client-side, propagated through the WebSocket Gateway, Collaboration Engine, Pub/Sub, and back down to every recipient client. This lets engineers answer “why did this specific keystroke take 400ms to appear for Ben?” by inspecting a single trace spanning every hop, rather than correlating disjoint logs across five services by hand.
11.4 SLOs and error budgets
Rather than chasing “zero errors,” the team defines explicit Service Level Objectives with an accompanying error budget — for example, “99.9% of edit operations propagate to co-editors within 200ms, measured over a rolling 30-day window.” As long as the system is within its error budget, feature teams can ship changes at normal velocity; once the budget is nearly exhausted (too many slow-propagation events in the window), the team shifts focus to reliability work before shipping new features. This turns “how reliable should we be” from an argument into a measurable, pre-agreed policy, and is the same discipline widely used at large-scale consumer platforms to balance shipping speed against user-facing reliability.
How would you detect a “silent” bug where two clients’ documents have quietly diverged (a CRDT correctness bug)? — Periodically compute and compare a lightweight state hash (e.g., a Merkle-tree-style digest of the CRDT structure) across the server’s authoritative copy and connected clients’ local copies, sent as a low-frequency background heartbeat. A hash mismatch signals divergence before a user ever notices garbled text, triggering an automatic “resync from server snapshot” for the affected client and an alert for engineers to investigate the root cause.
Deployment & Cloud Architecture
12.1 Containerized, orchestrated microservices
All stateless services run as containers on Kubernetes, with Horizontal Pod Autoscalers tuned to each service’s real bottleneck resource — CPU for API Gateway, connection-count/memory for the WebSocket Gateway, and a custom metric (assigned-shards-per-node) for the Collaboration Engine.
12.2 Infrastructure as Code
Terraform (or equivalent) defines all cloud resources — VPCs, managed Kafka clusters, managed MongoDB/PostgreSQL, load balancers — version-controlled and peer-reviewed like application code, enabling reproducible environments and safe disaster recovery rebuilds.
12.3 Deployment strategy
Stateless services use rolling or canary deployments (small percentage of traffic shifted to the new version, monitored, then ramped). The Collaboration Engine, being stateful per-shard, uses a careful drain-and-migrate strategy: new code is deployed to fresh nodes, existing document actors are gracefully migrated (snapshot + handoff) rather than killed outright, minimizing mid-session disruption for active writers.
12.4 Multi-region topology
12.5 CI/CD pipeline
Every merge to the main branch triggers an automated pipeline: unit tests, integration tests (including CRDT convergence property tests — randomized concurrent operation sequences checked for deterministic final-state equality), a staging deployment, automated smoke tests against staging, and finally a canary rollout to a small percentage of production traffic with automatic rollback if error rates or latency regress beyond defined thresholds. The CRDT convergence tests deserve special emphasis: because correctness here means “any interleaving of concurrent operations produces the same final state,” we use property-based/randomized testing (generating thousands of random concurrent operation orderings per test run) rather than relying solely on hand-written example-based tests, since subtle merge bugs often only appear under orderings a human wouldn’t think to write by hand.
12.6 Cost optimization
The two largest cost drivers in a system like this are typically WebSocket Gateway connection capacity (paying for idle-but-open connections) and the Document Store’s storage/IO for CRDT metadata overhead. Practical levers include: right-sizing gateway instances around connections-per-core rather than defaulting to generic compute-optimized instance types; using spot/preemptible instances for stateless, easily-restartable tiers (API Gateway, Search indexing workers) while keeping the stateful Collaboration Engine on stable, reserved capacity; aggressive tombstone garbage collection and snapshot compaction to control storage growth; and tiered storage — moving long-inactive stories’ full operation history to cheaper cold object storage while keeping only the latest snapshot readily queryable.
Two co-authors in different regions edit the same chapter — how do you keep them in sync without huge cross-ocean latency? — Each writer’s optimistic local edit applies instantly regardless of region, so perceived latency is always near-zero for their own keystrokes. Cross-region propagation to the other author happens asynchronously through replicated Pub/Sub and Kafka; because the CRDT merge is commutative and order-independent, both authors’ documents converge correctly even with 100-150ms of transoceanic replication lag — the trade-off is that each author sees the other’s changes with that added delay, which is an acceptable, well-understood cost versus routing every edit through one “master” region.
Databases, Caching & Load Balancing
13.1 Polyglot persistence rationale
| Store | Data | Why this choice |
|---|---|---|
| PostgreSQL | Users, permissions, story/chapter metadata, comments | Strong relational integrity, ACID transactions for permission changes |
| MongoDB (sharded) | CRDT snapshots & operation logs | Flexible binary/document schema, horizontal sharding by story_id |
| Redis | Sessions, hot document cache, presence, rate-limit counters | Sub-millisecond in-memory access for the hottest, most ephemeral data |
| Elasticsearch | Full-text search across published stories/tags | Purpose-built inverted-index search, relevance ranking |
| S3 (object storage) | Images, ebook exports, cold backups | Cheap, durable, effectively unlimited blob storage |
13.2 Sharding strategy for the Document Store
Shard key: story_id (hashed). This keeps every operation for a given story co-located on the same shard (avoiding cross-shard transactions for the common case), while spreading unrelated stories’ load evenly. A secondary index on author_id supports “my stories” queries via the Story Service’s PostgreSQL layer instead, avoiding expensive cross-shard scatter-gather queries against MongoDB.
13.3 Caching strategy
- Cache-aside for hot documents: the Collaboration Engine keeps actively-edited CRDT state in memory (itself acting as the “cache”), backed by Redis as a secondary warm cache for recently-active-but-idle documents, before falling back to MongoDB for cold documents.
- Write-through for session/auth data: Redis is updated synchronously alongside PostgreSQL writes for permission changes, since stale permission caches are a real security risk.
- CDN edge caching for published (immutable-until-republished) chapters and static assets, with cache invalidation triggered on the “chapter.published” Kafka event.
13.3.1 Handling embedded media and structured character data
Beyond plain prose, collaborative fiction platforms typically also support embedded images (cover art, character portraits, scene illustrations) and structured “character sheets” (name, description, relationships, stats for role-playing-style collaborative fiction). These are handled deliberately outside the CRDT text stream: an inline image is represented in the text CRDT only as a lightweight reference node (an ID pointing to an Object Storage asset), while the actual binary image data is uploaded separately via a conventional pre-signed-URL upload flow to S3, keeping the latency-critical text-editing path free of large binary payloads. Structured character-sheet data, meanwhile, is modeled as its own smaller CRDT-backed document (following the same merge principles as chapter text, since two co-authors might simultaneously edit a shared character’s backstory), but stored and cached separately from chapter prose since it has different access patterns — read far more often relative to how often it’s edited, and typically edited by a small, stable set of co-authors rather than large open crowds.
13.4 Load balancing details
Layer 7 load balancing with least-outstanding-requests for the API Gateway fleet (better than round-robin when request costs vary). For the WebSocket Gateway, connection-count-aware balancing distributes new connections to the least-loaded node, since each connection carries ongoing memory/CPU cost for the node’s lifetime, unlike a quick REST request.
Why not just put everything in one database to simplify operations? — Different data has fundamentally different access patterns and consistency needs — permission changes need ACID transactions (relational), CRDT operation logs need flexible schema and horizontal write scaling (document store), presence needs sub-millisecond ephemeral access (in-memory cache), and full-text search needs relevance-ranked inverted indexes (search engine). Forcing all of these into one database means picking the worst compromise for at least three of the four use cases; polyglot persistence trades operational complexity for each workload getting a purpose-built tool.
APIs & Microservices
14.1 REST API surface (via API Gateway)
POST /v1/auth/login
POST /v1/stories // create a new story
GET /v1/stories/{storyId}/chapters
POST /v1/stories/{storyId}/chapters // create a chapter (draft)
POST /v1/chapters/{chapterId}/publish
POST /v1/chapters/{chapterId}/branch // fork into alt-timeline
GET /v1/chapters/{chapterId}/history // version timeline
POST /v1/chapters/{chapterId}/comments
GET /v1/search?q=&tags=
WS /v1/realtime/chapters/{chapterId} // WebSocket upgrade for live editing
14.2 WebSocket protocol (simplified message shape)
public record RealtimeMessage(
String type, // "op" | "presence" | "ack" | "resync"
String chapterId,
String traceId,
Object payload // InsertOp / DeleteOp / CursorUpdate / SnapshotRef
) {}
public class RealtimeHandler {
public void onMessage(Session session, RealtimeMessage msg) {
switch (msg.type()) {
case "op" -> collabEngine.applyAndBroadcast(msg);
case "presence" -> presenceService.update(msg);
case "resync" -> session.send(collabEngine.snapshotFor(msg.chapterId()));
default -> logger.warn("Unknown message type: {}", msg.type());
}
}
}
14.3 Why microservices, and how they’re split
Services are split along independent scaling and failure boundaries, not just “one microservice per noun.” The Collaboration Engine (stateful, latency-critical) is deliberately separated from the Story Service (stateless CRUD) even though both touch “story” data, because they have wildly different scaling profiles — one scales on active-editor count, the other on request rate. Similarly, Moderation and Notification are decoupled via the message broker so a slow moderation model never adds latency to the live editing path.
14.4 Service-to-service communication
Synchronous gRPC for low-latency, request/response internal calls (e.g., Story Service checking a user’s permission with Auth Service); asynchronous Kafka events for anything that can tolerate eventual delivery and benefits from decoupling (moderation, notifications, search indexing). This split avoids the classic mistake of chaining synchronous calls across many services, which would multiply tail latency and create cascading failure risk.
14.5 API versioning & backward compatibility
The WebSocket protocol is the riskiest surface to version, since millions of already-connected clients can’t simply “refresh” mid-session without disrupting active writers. We version the protocol explicitly in the connection handshake (a client declares its supported protocol version) and require the Realtime Gateway and Collaboration Engine to support at least the current and previous protocol versions simultaneously during any rollout, giving already-connected older clients a grace window to naturally reconnect on their own schedule (e.g., on their next page load) rather than forcing an immediate, disruptive mass-disconnect. REST endpoints follow more conventional URL-path versioning (/v1/, /v2/) with deprecation windows communicated well in advance.
Would you use GraphQL instead of REST for the client-facing API here? — GraphQL is genuinely attractive for the read-heavy browsing surface (a story page needs metadata, chapter list, author info, and comment counts in one round trip, which GraphQL handles elegantly), so a hybrid is reasonable: GraphQL (or a BFF layer) for flexible reads, plain REST for simple writes/mutations, and a dedicated WebSocket protocol for the latency-critical live-editing path, which doesn’t fit request/response paradigms like REST or GraphQL at all.
Design Patterns & Anti-patterns
Naming the right design patterns by their established terminology signals to an interviewer that your design choices are grounded in well-understood industry practice rather than improvised on the spot. Each pattern below maps directly to a specific decision made earlier in this document, and it’s worth being able to explain not just what the pattern is, but which concrete problem in this system it was chosen to solve.
15.1 Patterns worth naming in an interview
Actor model
Each story/chapter’s Collaboration Engine state is an isolated “actor” processing messages sequentially — a clean way to reason about per-document concurrency without locks.
CQRS
Writes flow through the Collaboration Engine’s CRDT path; reads for browsing/search flow through separately-optimized read models (Elasticsearch, cached published snapshots) — command and query paths are deliberately decoupled.
Event sourcing
The operation log is the source of truth; current state (snapshots) is a derived, rebuildable projection — enabling full version history and time-travel for free.
Backpressure & bulkhead
Bounded queues and per-connection rate limits stop one runaway client or hot story from starving resources for everyone else.
Sidecar for tracing
OpenTelemetry instrumentation attached per-service without polluting core business logic, keeping observability a cross-cutting concern.
15.2 Anti-patterns to avoid
Each of these anti-patterns tends to look reasonable in isolation — often even appearing in an early prototype that “seems to work” in casual testing with two people in the same room — and only reveals its true cost once real, geographically distributed, flaky-network users start hammering the system concurrently at scale. Recognizing them early, before they’re load-bearing in production, is far cheaper than migrating away from them later.
Last-write-wins on rich text
Silently overwrites concurrent edits; acceptable for a single scalar field, catastrophic for shared prose.
Pessimistic paragraph locking
Kills the collaborative feel and creates availability risk if a lock-holder disconnects mid-edit.
Sticky-session-dependent gateways
Couples connection routing to document ownership, creating hot spots and painful failover.
Synchronous cross-service call chains
Calling Moderation → Notification → Search synchronously inside the write path multiplies latency and fragility; use async events instead.
One database for everything
Forces every workload into a compromise store poorly suited for at least some of them (see Section 13).
What’s the biggest anti-pattern you’d flag in a junior engineer’s first design for this system? — Treating the live editing path like a normal REST CRUD resource — “PUT the whole document on every change.” That approach doesn’t scale (sending the entire story text on every keystroke), doesn’t handle concurrency safely (whoever’s PUT lands last wins, silently destroying others’ work), and doesn’t support offline editing at all. The fix is operation-based (not document-replacement-based) synchronization from day one.
Best Practices & Common Mistakes
16.1 Best practices
- Apply edits optimistically on the client before server acknowledgment. Perceived responsiveness is the entire product experience here — a writer who feels even a hundred milliseconds of lag on their own typing will notice it far more than any latency in seeing someone else’s edits, so the client must render its own keystrokes instantly and reconcile with the server’s authoritative merge only in the background.
- Keep the WebSocket protocol operation-based, never full-document-based. Sending the entire chapter on every keystroke doesn’t just waste bandwidth — it makes offline replay and conflict merging impossible to reason about correctly, since you lose the fine-grained intent of what actually changed.
- Treat presence/awareness data as strictly ephemeral. Cursor positions and typing indicators should never sit on the same durable, latency-sensitive write path as actual story content; mixing the two risks slowing down or complicating the one path (content durability) that genuinely cannot afford to fail.
- Snapshot regularly to bound both crash-recovery time and per-session load time as stories grow into the hundreds of thousands of words across a long-running fandom epic. Without this, a popular, years-old collaborative story could eventually take unacceptably long to load for a new reader or reconnecting writer.
- Design the operation log to double as version history from day one. Retrofitting proper version control onto a system that wasn’t built event-sourced from the start is a substantially harder migration than building it in up front, since it usually requires backfilling history that was never actually captured.
- Validate every incoming operation server-side; never trust client-computed positions, IDs, or permissions blindly. A compromised or buggy client is a realistic threat model for any system this open, and the Collaboration Engine is the last line of defense against structurally invalid operations reaching the shared document.
- Separate the “draft” (live, evolving) and “published” (immutable snapshot) states explicitly at the data model level, not just in the UI — this single modeling decision prevents an entire category of “readers saw content silently change underneath them” bugs.
16.2 Testing strategy for a collaborative system
Testing this kind of system requires going beyond typical unit and integration tests. Three additional layers matter specifically here: property-based convergence testing, where thousands of randomly generated concurrent operation sequences are applied in every possible interleaving and checked for identical final results, catching the subtle merge-order bugs that hand-written example tests would likely miss; chaos/fault-injection testing (Section 9.5), which validates that failover and reconnection logic behave correctly under real, simulated failures rather than only in the happy path; and load testing with realistic typing patterns — synthetic clients that mimic actual human typing cadence, pauses, and burst-paste behavior, rather than a flat, unrealistic constant request rate, since real bottlenecks (like snapshot compaction timing or presence-update flooding) often only appear under realistic, bursty usage patterns rather than smooth synthetic load.
16.3 Common mistakes
- Confusing “eventual consistency” with “no consistency guarantees at all” — CRDTs must still mathematically guarantee convergence, not just “usually work.”
- Forgetting tombstone garbage collection until memory usage on long-running popular stories becomes a production incident.
- Coupling WebSocket Gateway scaling to Collaboration Engine scaling as if they were the same tier — they have different bottlenecks and should scale independently.
- Under-provisioning for the “viral story” hot-spot case, since traffic in social creative platforms is famously long-tail with sudden spikes.
- Skipping abuse/rate-limiting until after a public launch, when open collaborative writing is an obvious spam/vandalism magnet from day one.
Real-World & Industry Examples
Google Docs
Pioneered production real-time collaborative text editing using Operational Transformation with a centralized server serializing operation order — the direct ancestor of this system’s live-editing concept, though built for business documents rather than branching creative fiction.
Figma
Popularized CRDT-based collaboration for design files, proving that a CRDT-based architecture scales to complex, richly-structured documents (not just plain text) with many concurrent editors and strong offline tolerance — directly informing our choice of CRDT over OT.
Notion
Uses a block-based CRDT-influenced model where each block (paragraph, heading, list item) is its own conflict-resolved unit — a useful pattern for a story’s chapter/scene structure, where entire sections, not just characters, sometimes need independent merge semantics.
Archive of Our Own (AO3)
Demonstrates the community/tagging/moderation side at scale for fan-fiction specifically — strong lessons on tagging taxonomies, content warnings, and community-run moderation that this system’s Moderation Service design draws from.
Discord “story game” communities
Show the informal, chat-based version of this product’s core loop (turn-based collaborative storytelling) — useful for understanding lightweight, low-structure collaboration patterns that a full platform can formalize and improve on.
Studying these systems side by side reveals a recurring pattern worth internalizing for any collaborative-systems interview: the products that succeeded at scale (Google Docs, Figma) all made a deliberate, early, foundational choice about their conflict-resolution algorithm and never treated it as an implementation detail to bolt on later. Collaboration correctness is a load-bearing architectural decision, not a feature — it shapes what the data model can support (offline editing, branching, block-level structure), how the system scales, and how bugs manifest years later as the platform grows. Teams that under-invest in this decision early tend to pay for it later in the form of painful, user-visible data-corruption incidents that are difficult to retrofit away from.
“The best collaborative systems don’t just merge text correctly — they make everyone forget that merging is even happening.”A useful mental model borrowed from the Figma and Google Docs engineering teams’ public writing on real-time collaboration.
17.1 Lessons transferred into this design
| Source | Lesson | Applied here as |
|---|---|---|
| Google Docs (OT) | Centralized operation ordering simplifies reasoning but limits offline/peer-to-peer flexibility | Motivated choosing CRDT instead, given this platform’s offline and branching needs |
| Figma (CRDT) | CRDTs scale well beyond plain text to richly structured documents | Structural elements (chapters, comments, character sheets) modeled as nested CRDT-friendly structures, not just flat text |
| Notion (block model) | Independent per-block merge units reduce contention and simplify structural edits | Chapters/scenes can be treated as semi-independent actors for very large collaborative stories |
| AO3 (community & moderation) | Tagging, content warnings, and community-run moderation build trust at scale | Moderation Service design and tag-driven Search indexing |
Frequently Asked Questions
Can two people really type in the exact same sentence without breaking it?
Yes — this is exactly what the CRDT merge algorithm guarantees. Each character gets a unique, permanent ID and a reference to what it was inserted after, so two simultaneous inserts near the same spot are mathematically merged into a consistent order, never silently overwriting or corrupting each other.
What happens if I lose internet connection while writing?
Your edits keep applying locally on your device (optimistic local application), queued for sending. When your connection returns, those queued operations are sent and merged into the shared document exactly like any other concurrent edit — no special “conflict resolution” screen needed, because the CRDT model was designed for this from the start.
How is this different from just using Google Docs for a group story?
Google Docs handles the live-editing part well, but this platform adds story-specific structure: chapter/scene organization, branching alternate timelines, per-author attribution, fan-fiction-specific tagging and content warnings, publishing/versioning workflows, and community discovery/search — a purpose-built creative layer on top of real-time sync.
Can a story branch into multiple different endings?
Yes — branching clones the CRDT document state at a chosen point into a new, independent document actor with a recorded parent link, similar in spirit to a Git branch, allowing the story to diverge into “canon” and “alternate” paths that can later be compared or, in principle, selectively merged.
How do you stop trolls from vandalizing a popular collaborative story?
Layered defenses: per-user write-rate limiting, permission tiers (viewer/editor/co-author), automated moderation scanning, and — critically — a full replayable operation history, so any vandalism can be surgically reverted without losing legitimate contributions made around the same time.
Does every keystroke really get sent over the network individually?
Not quite — clients debounce and batch operations within small windows (roughly 20-50 milliseconds) before transmitting, so a fast typist’s burst of characters is typically sent as one compact message rather than dozens of individual network frames, which meaningfully reduces bandwidth and server-side processing overhead without adding perceptible latency.
Who “owns” a paragraph if five people wrote parts of it?
Attribution is tracked at the character-operation level — every inserted character’s CRDT metadata includes its originating author. The product layer can then render authorship coloring, contribution statistics, or royalty/credit splits (if monetized) directly from this data, without needing any separate, error-prone attribution-tracking system.
Can the system support voice-to-text or AI co-writing suggestions?
Yes — both integrate cleanly as just another client generating CRDT operations. A speech-to-text pipeline or an AI suggestion engine simply produces insert/delete operations through the same WebSocket protocol as a human typist, requiring no special-casing in the Collaboration Engine, though the product layer would typically flag AI-originated text distinctly for transparency.
What stops the operation log from growing forever and becoming unmanageable?
Two mechanisms working together: periodic snapshotting compacts the recent operation history into a single compact state (so replay only needs the log since the last snapshot, not the full history), and tombstone garbage collection reclaims space from deleted characters once no active or recently-offline replica could still reference them, keeping both memory and storage bounded even for stories edited continuously for years.
Summary & Key Takeaways
Designing a real-time collaborative story writing platform is, at its core, an exercise in taking a genuinely hard distributed-systems problem — many people editing the same fine-grained content simultaneously, sometimes offline, sometimes across continents — and solving it in a way that stays invisible to the end user. Every architectural decision in this document traces back to that single goal: a writer should be able to type freely, at any time, from anywhere, and trust that their words and everyone else’s will always come together correctly. The technology (CRDTs, sharded actors, event sourcing, polyglot persistence) exists entirely in service of that simple, human promise.
- The core hard problem is concurrent, character-level conflict resolution — solved here with a CRDT (RGA-style) rather than Operational Transformation, chosen specifically for offline tolerance and branching support.
- The architecture splits synchronous REST traffic (API Gateway) from long-lived real-time traffic (dedicated WebSocket Gateway), because they have fundamentally different scaling and failure characteristics.
- The Collaboration Engine holds authoritative, sharded, in-memory document state per story, backed by durable snapshotting and an event-sourced operation log for both recovery and version history.
- The system deliberately chooses AP (availability + partition tolerance) over strict consistency — a writer should never be blocked, and all replicas eventually, provably converge.
- Polyglot persistence — PostgreSQL, sharded MongoDB, Redis, Elasticsearch, and S3 — assigns each data shape to the store best suited for its access pattern rather than forcing one database to do everything.
- Security and moderation are first-class citizens, not afterthoughts, given the abuse surface of open, real-time public collaboration.
- Multi-region, active-active deployment is genuinely achievable here specifically because CRDTs tolerate the replication lag that would break a strictly-consistent design.
If asked to defend one decision above all others in an interview setting, defend the conflict-resolution strategy. Everything downstream — the shape of the WebSocket protocol, the sharding strategy, the multi-region deployment model, even the version-history and branching product features — flows naturally once CRDTs are chosen as the foundation, and would need to be substantially rethought under a different foundational choice. Strong system design answers are rarely about listing every possible technology; they’re about correctly identifying the one or two decisions the rest of the system genuinely depends on, and reasoning about those with real depth.