Building real-time collaborative apps without the complexity — introducing Meridian
Real-time collaboration is one of those features that sounds simple until you actually build it. Two users edit the same document simultaneously. Who wins? You implement last-write-wins. It works — until it doesn't, and one user's changes silently vanish.
The real solution is CRDTs (Conflict-free Replicated Data Types). But every CRDT library I found either required a proprietary backend, had vendor lock-in, or left too much infrastructure work on your plate.
So I built Meridian — an open-source, self-hosted real-time CRDT sync server. No vendor lock-in. No merge conflicts. Deploy on your own infra or at the edge on Cloudflare Workers.
The problem with real-time sync
Most teams start with WebSockets + a shared database. It works for simple cases. But as soon as you have concurrent edits, you hit the fundamental problem: two clients disagree on the truth, and your server has to pick a winner.
Operational Transforms (the approach behind Google Docs) solve this but are notoriously complex to implement correctly. CRDTs take a different approach: instead of resolving conflicts after the fact, they design data structures that are mathematically guaranteed to converge — regardless of the order operations are applied.
No conflicts. No resolution code. Just convergence.
What Meridian gives you
Meridian is a Rust server that stores CRDT state, broadcasts deltas over WebSocket, and provides a TypeScript SDK to consume it from any frontend.
MERIDIAN_SIGNING_KEY=$(openssl rand -hex 32) docker compose up -d
bun add meridian-sdk
import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
const client = await Effect.runPromise(
MeridianClient.create({
url: "http://localhost:3000",
namespace: "my-room",
token: process.env.MERIDIAN_TOKEN!,
})
);
const views = client.gcounter("gc:views");
views.increment(1);
views.onChange(v => console.log("views:", v));
Every client subscribed to gc:views sees the same value, instantly, across all nodes.
Six CRDT types out of the box
| Type | Use case |
|---|---|
GCounter |
Page views, likes, download counts |
PNCounter |
Inventory, vote tallies |
ORSet |
Shopping cart, tag sets, collaborative lists |
LwwRegister |
User profile, document title, config |
Presence |
Who's online, cursor positions, typing indicators |
CRDTMap |
Structured document with independent typed fields |
Plus Awareness — an ephemeral, non-persisted channel for high-frequency state like live cursors and "is typing" indicators. Updates fan out in real time without hitting the WAL.
React in three lines
import { MeridianProvider, useGCounter, usePresence } from "meridian-react";
function Room() {
const { value, increment } = useGCounter("gc:views");
const { peers } = usePresence("pr:room");
return (
<p>
{value} views · {peers.length} online
<button onClick={() => increment(1)}>+1</button>
</p>
);
}
function App() {
return (
<MeridianProvider client={client}>
<Room />
</MeridianProvider>
);
}
No global state management. No manual subscription cleanup. The hook handles everything.
Deploy anywhere — native or edge
One of Meridian's design goals was zero SDK changes between deployment targets.
Native (Docker, bare metal, Kubernetes):
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
Cloudflare Workers (Durable Objects, WASM):
MeridianClient.create({ url: "https://my-worker.workers.dev", namespace: "my-room", token })
Same protocol, same SDK, different URL. The edge runtime compiles to WASM via wasm-bindgen and uses Durable Objects for per-namespace state.
Devtools that actually help
Debugging real-time state is painful when you can't see what's happening. Meridian ships a React devtools panel with four tabs:
- CRDT inspector — live view of every CRDT value and its type
- Event stream — every op as it arrives, with timestamps
- WAL history — time-travel through the write-ahead log, inspect past state at any sequence number
- Connection — WebSocket status, reconnection state, and live op latency (P50/P99 over a rolling 128-sample window)
bun add meridian-devtools
import { MeridianDevtools } from "meridian-devtools";
<MeridianDevtools client={client} />
The latency display is especially useful in prod — you can see P50/P99 round-trip in real time without any external tooling.
CLI for terminal debugging
bun add -g meridian-cli
meridian inspect <crdt-id> # stream WAL history entries
meridian replay <crdt-id> # replay ops against a local server
Useful for CI pipelines, post-mortem debugging, or just understanding what happened to a CRDT without spinning up a browser.
WAL archiving to S3
For production deployments where you want point-in-time recovery, Meridian supports transparent WAL archiving to any S3-compatible object store (AWS S3, Cloudflare R2, MinIO):
S3_BUCKET=my-wal-bucket \
S3_ENDPOINT=https://my-r2-account.r2.cloudflarestorage.com \
S3_REGION=auto \
docker compose up -d
Enable it with the wal-archive-s3 feature flag at compile time. The archive is a transparent wrapper around any WAL backend — segments are uploaded before truncation, restored on startup if the local WAL is empty. Upload failures are non-fatal: they log a warning but never block writes.
Built on Effect TS
The SDK is built on Effect — a TypeScript library for type-safe errors, composable async, and runtime schema validation. Every CRDT handle exposes a stream() method that returns an Effect Stream, composable with the full Effect ecosystem:
import { Stream, Effect } from "effect";
await Effect.runPromise(
views.stream().pipe(
Stream.take(5),
Stream.runForEach(v => Effect.log(`views: ${v}`))
)
);
Auth uses custom ed25519-signed tokens — smaller than JWT, with constant-time verification.
Try it
- GitHub: https://github.com/Chahine-tech/meridian
-
npm:
bun add meridian-sdk
If you're building anything collaborative — shared whiteboards, multiplayer games, live dashboards, document editors — Meridian is worth a look. The server is MIT-licensed, self-hostable in one Docker command, and the SDK has zero mandatory cloud dependencies.
Feedback welcome — open an issue or drop a comment.
Top comments (0)