DEV Community

Cover image for Meridian
chahine benlahcen
chahine benlahcen

Posted on

Meridian

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
Enter fullscreen mode Exit fullscreen mode
bun add meridian-sdk
Enter fullscreen mode Exit fullscreen mode
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));
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

Cloudflare Workers (Durable Objects, WASM):

MeridianClient.create({ url: "https://my-worker.workers.dev", namespace: "my-room", token })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
import { MeridianDevtools } from "meridian-devtools";

<MeridianDevtools client={client} />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`))
  )
);
Enter fullscreen mode Exit fullscreen mode

Auth uses custom ed25519-signed tokens — smaller than JWT, with constant-time verification.


Try it

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)