Skip to content

About

Google Docs–style real-time editor: FastAPI + Yjs/pycrdt CRDT sync, PostgreSQL, Redis, React + TypeScript. Offline editing, version history, sharing, 250+ tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

59 Commits

Folders and files

Repository files navigation

CollabEdit

Real-time collaborative documents, in the spirit of Google Docs: live cursors, offline editing, comments, version history, full-text search and link sharing — on a sync server, storage layer and auth system written from scratch. And something Google Docs lacks: Git-style branches, so you can fork a live document, rework it on the side, and merge it back after review.

CI  Live demo · Plan and progress · Architecture decisions · Deployment

Two people editing the same document: Rahul's caret and name appear live in Priya's window, with both avatars and "All changes synced" in the header

Status: live on Vercel (web) and Render (API) with Neon Postgres and Redis Cloud. Phases 0–8 of the v2 plan are done — accounts with Google sign-in, password reset and verified email addresses, live editing with offline support, compaction, version history, search, sharing, comments, branches with merge requests, and several API instances sharing live documents through Redis. Next: export and an installable offline app, an AI assistant, and hardening. The original MERN version is preserved under the v1 tag.

Try the live demo with Continue with Google or a new email account. The API runs on Render's free plan, which sleeps after about 15 idle minutes: the first request after a quiet spell waits while it wakes, usually under a minute.

Highlights

  • Own sync server. FastAPI WebSockets speak the Yjs sync and awareness protocols on top of pycrdt (Rust Yrs): one room per open document, per-connection send queues, and roles enforced on every update (ADR 0012). In production, edits reach other users in about 110 ms.
  • Edit log + compaction. Edits are batched into an append-only Postgres log (every 500 ms or 50 updates) and folded into snapshots under an advisory lock. Opening a 10,000-keystroke document went from 218 ms to 17 ms, and a 50,000-keystroke one from 6.5 s to 0.28 s (ADR 0003).
  • Branches and merge requests. Fork a document, edit the branch live with others, take main's newer changes, and merge after review. The merge is a CRDT merge, which never fails but would quietly interleave two rewrites of one paragraph, or drop edits to a paragraph the other side deleted. So review runs a three-way diff (git's diff3, over paragraphs) and blocks the merge on conflicts; commenters can propose changes this way without edit rights (ADR 0016).
  • Comments that stay on their words. Threads are anchored with Yjs relative positions, kept beside the text instead of in it, so commenters need no edit rights and a highlight follows its passage through local, remote and offline edits. Changes reach open editors over the document's socket; new comments email the people involved (ADR 0017).
  • Scales out. Any number of API instances serve one document together: rooms sync through Redis pub/sub, run the Yjs handshake between servers when a room opens or a connection returns, and publish nothing at all when an instance is alone with a document. Two instances behind nginx, measured with k6: 99.9% of 910,000 edit deliveries in a minute, median 4 ms (ADR 0004).
  • Self-built rate limiter. A token bucket in one Redis Lua script: atomic across API instances in a single round trip, failing open or closed per policy (ADR 0005).
  • Careful sessions. Argon2id passwords, 15-minute JWTs, rotating refresh tokens with reuse detection (ADR 0011), and one-time 30-second WebSocket tickets (ADR 0006).
  • Sign in with Google, done properly. Authorization code flow with PKCE, browser-bound state and nonce, ID tokens checked against Google's keys; accounts matched by Google's id, and an address registered by someone who never proved it is cleaned before Google links it (pre-hijacking). Password resets and verification links are single use, hashed, and kept out of server logs (ADR 0018).
  • Offline-first. The browser keeps an IndexedDB copy of each document; edits made offline merge when the connection returns, and the copies are wiped on sign-out or when access is revoked.
  • Tested. 282 API tests against real Postgres and Redis, two API instances in one process and a fake Google, including Hypothesis properties for CRDT convergence, compaction and diff3; and 117 web tests. GitHub Actions runs them on every push, then starts two instances behind nginx and load-tests them with k6.

Architecture

flowchart TB
  subgraph Browser
    direction LR
    UI["React + Tiptap"] --- YD["Yjs document"] --- IDB[("IndexedDB copy")]
  end
  subgraph API["FastAPI on Render"]
    direction LR
    REST["REST API<br/>auth · documents · sharing · comments<br/>versions · branches · search"]
    ROOMS["Sync rooms<br/>pycrdt, one per open document or branch"]
  end
  subgraph Data["Managed data"]
    direction LR
    PG[("PostgreSQL<br/>edit log + snapshots")]
    RD[("Redis<br/>rate limits · WebSocket tickets<br/>pub/sub between instances")]
  end
  MAIL["SMTP (Gmail)<br/>verification · password reset · comments"]
  GOOGLE["Google<br/>OAuth sign-in"]
  UI -- "HTTPS /api · JWT" --> REST
  YD <-- "WebSocket · Yjs sync + awareness" --> ROOMS
  REST --> PG
  ROOMS -- "batched edits" --> PG
  REST --> RD
  ROOMS <-- "edits · cursors · events" --> RD
  REST -.-> MAIL
  REST -.-> GOOGLE
Loading

Stack

Layer Technology
Web React 19, TypeScript, Vite, TanStack Query, React Router, Tailwind CSS
Editing Tiptap (ProseMirror) + Yjs in the browser, pycrdt sync server over WebSockets
API Python 3.13, FastAPI, SQLAlchemy 2 (async), Alembic
Data PostgreSQL, Redis
Hosting Vercel (web), Render (API), Neon (Postgres), Redis Cloud, Gmail SMTP (email)
Tooling uv, ruff, mypy, pytest, oxlint, Prettier, Vitest, GitHub Actions, Docker Compose, nginx, k6

Local development

Prerequisites: Docker Desktop, Python 3.13 with uv, and Node.js 22+.

# 1. Postgres and Redis
docker compose -f infra/docker-compose.yml up -d

# 2. API on http://localhost:8000 (docs at /api/docs)
cd apps/api
cp .env.example .env
uv sync
uv run alembic upgrade head
uv run uvicorn app.main:app --reload --no-access-log

# 3. Web app on http://localhost:5173 (proxies /api to the API)
cd apps/web
npm install
npm run dev

To run the API as two instances behind nginx instead of step 2 (on the same port, so step 3 works unchanged), see edits cross between them, and stop one to watch editors move to the other:

docker compose -f infra/docker-compose.yml --profile full up -d --build
curl -s localhost:8000/api/healthz   # repeat: "instance" alternates

Open http://localhost:5173, create an account, and create a document. Open it in a second window to watch edits and cursors sync live; stop the API to try offline editing. The dashboard footer shows the API's readiness: green when Postgres, Redis and the pub/sub connection all answer.

Without SMTP settings the API prints each email (verification, password reset, comment notifications) to its log instead of sending it, so you can follow the links. To try Google sign-in locally, add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to apps/api/.env (DEPLOY.md, step 9); without them the Google button is hidden.

Checks

Run the same checks CI runs before pushing. API tests need the Docker services from step 1; they build a separate collabedit_test database from the migrations and use Redis database 15.

# apps/api
uv run ruff check . && uv run ruff format --check . && uv run mypy app tests && uv run pytest

# apps/web
npm run lint && npm run typecheck && npm run format:check && npm test && npm run build

# the production image as two instances behind nginx, load-tested (CI runs this too)
DOCS=4 PER_DOC=4 DURATION=20 infra/k6/run.sh

If CI's npm ci fails with Missing: … from lock file, the lockfile was written by an npm that dropped an entry (npm 11.6 can omit transitive dependencies of optional packages). Rebuild it with the npm CI uses: npx npm@10 install --package-lock-only.

Benchmarks

How much compaction speeds up opening a document (ADR 0003 has the results):

# apps/api, with the Docker services running and migrations applied
uv run python -m benchmarks.load_document             # 10,000 keystrokes
uv run python -m benchmarks.load_document --edits 50000

Edit fan-out across instances, with k6 against two instances behind nginx (ADR 0004 has the full table):

infra/k6/run.sh                    # 20 documents x 5 editors, 60 s
API_REPLICAS=1 infra/k6/run.sh     # the same on one instance
DOCS=4 PER_DOC=20 EDIT_MS=100 infra/k6/run.sh   # four busy documents
Load Instances p50 p95 Redis commands per edit
100 editors, 2 edits/s each 1 1 ms 1 ms ≈ 0 (one per document opened)
same 2 1 ms 17 ms 0.89
80 editors on 4 documents, 10 edits/s each 2 4 ms 48 ms 0.20

Layout

apps/api/     FastAPI service (app/core, app/<domain>, alembic, tests)
apps/web/     React app (src/app, src/lib, src/features)
infra/        docker-compose.yml (Postgres, Redis, API instances, nginx), k6 load test
docs/         PLAN.md and architecture decision records (docs/adr)

About

Google Docs–style real-time editor: FastAPI + Yjs/pycrdt CRDT sync, PostgreSQL, Redis, React + TypeScript. Offline editing, version history, sharing, 250+ tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages