Build a Real-Time Collaborative Notes App
Build a full-stack collaborative editor with CRDTs, WebSockets, and task queues.
Introduction
30 Second Summary
Think about how Google Docs lets multiple people edit the same document at once. Behind the scenes, there is a complex system resolving everyone's changes without conflicts or data loss.
In this project, you will build CollabNotes, a real-time collaborative note-taking platform with an integrated task scheduling system. You will implement the same conflict-resolution technology used by tools like Google Docs and Figma, all running locally on your machine.
What You'll Build
CollabNotes is a full-stack collaborative note-taking application for CS students, featuring real-time multi-user editing with live cursors, organized study categories, and automated study reminders powered by a distributed task queue.
By the end of this project, you'll have:
- A 3-tier architecture with a React frontend, an Express.js API, a Hocuspocus WebSocket server for real-time collaboration, and a background task worker.
- A real-time collaborative editor using Yjs CRDTs with Tiptap, achieving conflict-free multi-user editing with live cursors and presence awareness.
- A task scheduling system using BullMQ and Redis for study reminders, spaced repetition schedules, and cron-based recurring jobs with retry logic.
- Secret Mission: Add offline editing support with IndexedDB persistence and automatic CRDT-based sync on reconnect.
Are there any prerequisites?
This is an advanced project. You should have intermediate JavaScript/TypeScript experience, a basic understanding of REST APIs, familiarity with React, and basic SQL knowledge.
No prior experience with CRDTs, WebSockets, or task queues is required. You will learn all of those from scratch.
Set Up Infrastructure and Monorepo
CollabNotes is a multi-service application with four distinct processes: a React frontend, an Express API server, a Hocuspocus collaboration server, and a BullMQ task worker.
In this step, you will create the monorepo folder structure to house all four services. You will also spin up PostgreSQL and Redis containers using Docker Compose so the data layer is ready before you write any application code.
In this step, get ready to:
- Create the monorepo folder structure with four service directories.
- Set up PostgreSQL and Redis containers with Docker Compose.
Create the monorepo project structure
A monorepo keeps all related services in one repository. Each directory will become an independently runnable Node.js project with its own package.json.
- Open your terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir collabnotes
cd collabnotes
- Create the four service directories by running this command:
mkdir client server collab-server worker
- Confirm the folders exist by running:
ls
You should see client, server, collab-server, and worker listed in the output.
Why four directories?
Each directory maps to one tier of the architecture. client is the React presentation layer. server is the Express REST API. collab-server handles real-time WebSocket synchronization. worker runs background task processing.
Separating them lets you scale, deploy, and reason about each service independently.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the collabnotes folder, and click Open.
You should see the four subdirectories listed in the Explorer sidebar on the left.
Don't see the folders in the sidebar?
Make sure you opened the collabnotes folder itself, not one of the subdirectories inside it. The Explorer sidebar should show client, server, collab-server, and worker at the top level.
help me find and open my collabnotes project folder in VS Code
Configure Docker Compose for PostgreSQL and Redis
CollabNotes needs two infrastructure services. PostgreSQL stores user accounts, notes, categories, and task schedules. Redis serves double duty as the Yjs document cache and the BullMQ job queue backend.
- In VS Code, right-click in the Explorer sidebar and select New File.
- Name the file docker-compose.yml and press Enter.
- Paste the following configuration into docker-compose.yml:
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: collabnotes
POSTGRES_PASSWORD: collabnotes_dev
POSTGRES_DB: collabnotes
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory-policy noeviction
ports:
- "6379:6379"
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
What does this configuration do?
- postgres:16-alpine pulls the lightweight Alpine-based PostgreSQL 16 image. The environment variables create a database called collabnotes with matching credentials on first boot.
- redis:7-alpine runs Redis 7 with append-only persistence so data survives container restarts.
- --maxmemory-policy noeviction prevents Redis from silently dropping keys when memory fills up. BullMQ requires this setting to function reliably.
- Named volumes (pgdata and redisdata) persist data outside the container lifecycle. Stopping and restarting containers keeps your data intact.
- Save docker-compose.yml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, both platforms).
- Start both containers in the background by running:
docker compose up -d
Docker will pull the images on the first run, which may take a minute.
- Verify both containers are running by running:
docker compose ps
You should see two containers listed with a STATUS showing Up: one for postgres on port 5432 and one for redis on port 6379.
Containers not starting?
Make sure Docker Desktop is running. Look for the Docker whale icon in your system tray (macOS menu bar or Windows taskbar). If it is not there, open Docker Desktop first.
If you see a port conflict error, another process is already using port 5432 or 6379. Stop the conflicting process or change the host port in docker-compose.yml (for example, "5433:5432").
help me troubleshoot my Docker Compose containers not starting for the CollabNotes project
✔️ Awesome, I've got everything!
Great. Make sure you have saved docker-compose.yml and both containers show Up in the docker compose ps output.
ⓧ I'd like to double check the full code
Here is the complete docker-compose.yml file. Compare yours character by character.
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: collabnotes
POSTGRES_PASSWORD: collabnotes_dev
POSTGRES_DB: collabnotes
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory-policy noeviction
ports:
- "6379:6379"
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
Your infrastructure is live. Next up, you will design the database schema with Prisma and seed it with the initial note categories.
Design the Database Schema with Prisma
Your Docker infrastructure is running. You have a PostgreSQL database and Redis cache ready to accept connections.
Now you need to define how your application stores data. A collaborative note-taking platform has users, notes, categories, tags, tasks, and schedules. These entities form a relational graph. Prisma ORM lets you define this graph in a single schema file and handles the SQL generation for you.
In this step, get ready to:
- Initialize Prisma in the server directory and configure the PostgreSQL connection.
- Design the relational schema with Users, Notes, Categories, Tags, Tasks, and TaskSchedules.
- Run migrations and seed the database with initial categories.
Initialize Prisma and configure the database connection
Before Prisma can manage your database, you need to install it and point it at your running PostgreSQL container. The connection uses the same credentials you defined in docker-compose.yml.
- Switch to the server directory by running this command:
cd server
- Initialize a Node.js project and install Prisma by running these commands:
npm init -y
npm install prisma --save-dev
npm install @prisma/client
What do these packages do?
The prisma package is the CLI tool that generates migrations and manages your schema. The @prisma/client package is the query library your application code imports to talk to the database.
- Initialize Prisma with PostgreSQL as the datasource provider by running:
npx prisma init --datasource-provider postgresql
What does this command do?
- Creates a prisma/ directory with a schema.prisma file for defining your data models.
- Creates a .env file for your database connection string.
- Open the .env file inside server/ and replace the placeholder DATABASE_URL with the connection string for your Docker PostgreSQL container:
DATABASE_URL="postgresql://collabnotes:collabnotes_dev@localhost:5432/collabnotes"
Why these credentials?
These match the POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB values you set in docker-compose.yml. Prisma uses this URL to connect to the exact PostgreSQL instance running in your Docker container on port 5432.
- Confirm the files were created by running:
ls prisma/
You should see schema.prisma listed in the output.
Don't see the prisma folder?
Make sure you ran npx prisma init from inside the server/ directory, not from the project root. Check your current directory with pwd.
Help me troubleshoot my Prisma initialization. I ran npx prisma init but don't see the prisma folder.
Design the relational schema
The schema defines every table, column, relationship, and index in your database. You will model users who own notes, notes that belong to categories, tags with many-to-many relationships, and tasks with optional cron schedules.
- Open server/prisma/schema.prisma in VS Code and replace its entire contents with the following schema:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
color String @default("#6366f1")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notes Note[]
tasks Task[]
}
model Category {
id String @id @default(uuid())
name String @unique
slug String @unique
notes Note[]
}
model Note {
id String @id @default(uuid())
title String
categoryId String
authorId String
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id])
author User @relation(fields: [authorId], references: [id])
tags Tag[]
@@index([categoryId])
@@index([authorId])
}
model Tag {
id String @id @default(uuid())
name String @unique
notes Note[]
}
model Task {
id String @id @default(uuid())
title String
description String?
userId String
noteId String?
status TaskStatus @default(PENDING)
priority Int @default(0)
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
schedule TaskSchedule?
@@index([userId])
@@index([status])
}
model TaskSchedule {
id String @id @default(uuid())
taskId String @unique
pattern String
timezone String @default("UTC")
isActive Boolean @default(true)
task Task @relation(fields: [taskId], references: [id])
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
FAILED
}
What does this schema define?
- User stores authentication credentials and a color field for collaboration cursors.
- Category groups notes into CS topics (DSA, OS, OOP, System Design).
- Note belongs to one Category and one User. The @@index directives speed up queries that filter by category or author.
- Tag has an implicit many-to-many relationship with Note (Prisma creates the join table automatically).
- Task and TaskSchedule model study reminders. A TaskSchedule stores a cron pattern for recurring jobs.
- TaskStatus is a PostgreSQL enum that constrains task state transitions.
Using UUIDs (@default(uuid())) instead of auto-incrementing integers prevents ID collisions across distributed services. This is an important interview point.
Why PostgreSQL over MongoDB for this project?
The data is inherently relational. Users have notes, notes belong to categories, tasks reference notes, and schedules reference tasks. PostgreSQL enforces these relationships at the database level, preventing orphaned records.
The actual note content (rich text) is stored as binary Yjs updates in a separate table, keeping relational integrity for metadata while efficiently storing CRDT state.
- Save schema.prisma.
✔️ Awesome, I've got everything!
Great. Double check you saved your file before moving on.
ⓧ I'd like to double check the full code
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
color String @default("#6366f1")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notes Note[]
tasks Task[]
}
model Category {
id String @id @default(uuid())
name String @unique
slug String @unique
notes Note[]
}
model Note {
id String @id @default(uuid())
title String
categoryId String
authorId String
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id])
author User @relation(fields: [authorId], references: [id])
tags Tag[]
@@index([categoryId])
@@index([authorId])
}
model Tag {
id String @id @default(uuid())
name String @unique
notes Note[]
}
model Task {
id String @id @default(uuid())
title String
description String?
userId String
noteId String?
status TaskStatus @default(PENDING)
priority Int @default(0)
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
schedule TaskSchedule?
@@index([userId])
@@index([status])
}
model TaskSchedule {
id String @id @default(uuid())
taskId String @unique
pattern String
timezone String @default("UTC")
isActive Boolean @default(true)
task Task @relation(fields: [taskId], references: [id])
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
FAILED
}
Run migrations and seed the database
With the schema defined, you need to translate it into actual PostgreSQL tables. Prisma Migrate reads your schema, generates SQL, and applies it to the running database.
- Create and apply your first migration by running this command from the server/ directory:
npx prisma migrate dev --name init
What does this command do?
- Generates a SQL migration file in prisma/migrations/ that creates all your tables, indexes, and enums.
- Applies that SQL to your PostgreSQL Docker container.
- Updates the _prisma_migrations table so Prisma tracks what has been applied.
The --name init flag names this migration "init" so you can identify it later in your migration history.
You should see output confirming the migration was applied successfully and the Prisma Client was generated.
Seeing a connection error?
Make sure your Docker containers are still running. Return to your terminal at the project root and run docker compose ps to check. If the postgres container is not running, start it with docker compose up -d.
Also verify that your .env file uses localhost (not postgres) since Prisma runs on your host machine, not inside Docker.
Help me fix a Prisma connection error when running migrate dev against my Docker PostgreSQL.
Next, you need to create the YjsDocument table that the Hocuspocus collaboration server will use to persist document state. This table stores binary Yjs updates and is accessed via raw SQL, so it does not need a Prisma model.
- Create a SQL file for the YjsDocument table by running:
mkdir -p prisma/sql
- Create a file called prisma/sql/yjs_table.sql with the following contents:
-- Create the YjsDocument table for Hocuspocus document persistence
CREATE TABLE IF NOT EXISTS "YjsDocument" (
"name" TEXT NOT NULL UNIQUE,
"data" BYTEA,
"updatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
Why a raw SQL table instead of a Prisma model?
The Hocuspocus server accesses this table via raw SQL queries ($queryRaw and $executeRaw) with an ON CONFLICT upsert pattern. Keeping it outside the Prisma schema avoids generating unnecessary model code for a table that only stores opaque binary blobs.
- Apply the SQL file to your database by running:
npx prisma db execute --file ./prisma/sql/yjs_table.sql
You should see a success message with no errors.
Now seed the database with the initial note categories. You will create a seed script that inserts the four CS categories.
- Create a file called prisma/seed.ts with the following contents:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// Seed the four CS study categories
const categories = [
{ name: "DSA", slug: "dsa" },
{ name: "Operating Systems", slug: "operating-systems" },
{ name: "OOP", slug: "oop" },
{ name: "System Design", slug: "system-design" },
];
for (const category of categories) {
await prisma.category.upsert({
where: { slug: category.slug },
update: {},
create: category,
});
}
console.log("Seeded 4 categories");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Why upsert instead of create?
Using upsert makes the seed script idempotent. If you run it multiple times, it will not create duplicate categories. The where clause checks for an existing slug, and the empty update means "do nothing if it already exists."
- Install tsx as a dev dependency to run TypeScript files directly:
npm install tsx --save-dev
- Add the seed configuration to package.json. Open server/package.json and add the following prisma block at the top level (after the "devDependencies" block):
"prisma": {
"seed": "tsx prisma/seed.ts"
}
- Run the seed script by executing:
npx prisma db seed
You should see Seeded 4 categories in the output.
Seed script failing?
If you see a "Cannot find module" error, make sure tsx is installed and that the "prisma" block in package.json uses the exact command tsx prisma/seed.ts.
If you see a connection error, verify your Docker PostgreSQL container is still running and the .env file has the correct DATABASE_URL.
Help me debug why my Prisma seed script is failing.
- Finally, generate the Prisma Client to ensure it is up to date by running:
npx prisma generate
This generates the type-safe query client that your Express server will use in the next step.
Your database schema is in place with all tables, relationships, and indexes created. Next up, you will build the Express.js REST API that authenticates users and serves note data from this schema.
Build the Authentication and REST API
Your database is ready with a full relational schema. Now you need an application layer that sits between the database and the frontend.
In this step, you will build the Express.js REST API that handles user authentication, note management, and task scheduling. This server forms the middle tier of your 3-tier architecture.
In this step, get ready to:
- Set up Express.js with TypeScript and configure middleware.
- Implement JWT-based authentication with register and login endpoints.
- Build the Notes and Tasks CRUD API.
Set up the Express.js server with TypeScript
The server needs several packages to function. Express handles HTTP routing. CORS allows cross-origin requests from the React frontend. JSON Web Tokens provide stateless authentication. Bcrypt hashes passwords securely.
- Install the production dependencies by running this command inside the server directory:
npm install express cors jsonwebtoken bcryptjs
- Install the TypeScript tooling and type definitions as dev dependencies by running:
npm install -D typescript ts-node nodemon @types/express @types/cors @types/jsonwebtoken @types/bcryptjs @types/node
What are these dev dependencies?
TypeScript is the compiler. ts-node lets you run .ts files directly without a separate compile step. nodemon watches for file changes and auto-restarts the server during development.
The @types/* packages provide TypeScript type definitions for the JavaScript libraries you just installed.
Install command failing?
- Make sure you are inside the server directory. Run pwd to check your current location.
- If you see permission errors, try closing and reopening your terminal.
Help me fix an npm install error in my server directory.
- Create the TypeScript configuration file at server/tsconfig.json with this content:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
What does this configuration do?
The esModuleInterop flag lets you use import express from "express" syntax instead of the clunkier import * as express form. The strict flag enables all strict type-checking options for better code safety.
- Create the src directory structure inside server by running:
mkdir -p src/routes src/middleware
- Confirm the directories exist by running:
ls src
You should see middleware and routes in the output.
- Create the error handler middleware at server/src/middleware/errorHandler.ts with this code:
import { Request, Response, NextFunction } from "express";
// Global error handler catches unhandled errors from route handlers
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
}
What does this code do?
Express recognizes error-handling middleware by its four-parameter signature (err, req, res, next). Any unhandled error thrown in a route automatically flows here, returning a clean 500 response instead of crashing the server.
- Create the main server entry point at server/src/index.ts with this code:
import express from "express";
import cors from "cors";
import { authRouter } from "./routes/auth";
import { notesRouter } from "./routes/notes";
import { tasksRouter } from "./routes/tasks";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
const PORT = process.env.PORT || 3001;
// Allow requests from the React frontend
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
// Parse JSON request bodies
app.use(express.json());
// Mount route modules
app.use("/api/auth", authRouter);
app.use("/api/notes", notesRouter);
app.use("/api/tasks", tasksRouter);
// Global error handler must be registered last
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`API server running on port ${PORT}`);
});
What does this code do?
- CORS is configured to accept requests from http://localhost:5173 where the Vite React frontend will run.
- Three route modules are mounted at /api/auth, /api/notes, and /api/tasks.
- The error handler is registered last so it catches errors from all routes above it.
The server file imports routes that do not exist yet. You will create them in the next substeps.
- Save errorHandler.ts and index.ts.
Implement JWT authentication middleware and routes
Every API request (except login and register) needs to prove the caller is a valid user. JWT tokens let you do this without server-side sessions. The server signs a token on login, and the client sends it back on every subsequent request.
- Create the authentication middleware at server/src/middleware/auth.ts with this code:
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "collabnotes-dev-secret";
// Extend the Express Request type to include userId
export interface AuthRequest extends Request {
userId?: string;
}
// Middleware that validates Bearer JWT tokens on incoming requests
export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid token" });
return;
}
const token = authHeader.split(" ")[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string };
req.userId = payload.userId;
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
// Helper to sign a new JWT with 7-day expiry
export function signToken(userId: string): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: "7d" });
}
What does this code do?
- The AuthRequest interface extends Express's Request type to include a userId property that gets attached after token verification.
- The authenticate function checks for a Bearer token in the Authorization header, verifies it against the secret, and passes userId to downstream handlers.
- The signToken helper creates tokens that expire in 7 days, keeping sessions alive without frequent re-logins.
- Save auth.ts.
- Create the authentication routes at server/src/routes/auth.ts with this code:
import { Router } from "express";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";
import { signToken } from "../middleware/auth";
const prisma = new PrismaClient();
export const authRouter = Router();
// Register a new user account
authRouter.post("/register", async (req, res) => {
const { email, name, password } = req.body;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.status(409).json({ error: "Email already registered" });
return;
}
// Hash password with bcrypt (10 salt rounds)
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { email, name, password: hashedPassword },
});
const token = signToken(user.id);
res.status(201).json({ token, user: { id: user.id, email: user.email, name: user.name, color: user.color } });
});
// Log in an existing user
authRouter.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
// Compare submitted password against stored hash
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const token = signToken(user.id);
res.json({ token, user: { id: user.id, email: user.email, name: user.name, color: user.color } });
});
What does this code do?
- The /register endpoint checks for duplicate emails, hashes the password with bcrypt (salt rounds of 10), creates the user in PostgreSQL, and returns a signed JWT.
- The /login endpoint looks up the user by email, compares the hashed password, and returns a token if valid.
- Both endpoints return the user object without the password field, keeping sensitive data out of the response.
Why bcrypt with salt rounds of 10?
Bcrypt is a deliberately slow hashing algorithm designed for passwords. The salt rounds parameter (10) means the hash is computed 2^10 (1,024) times. This makes brute-force attacks impractical while keeping login response times under 100ms.
- Save server/src/routes/auth.ts.
Build the Notes and Tasks CRUD API
With authentication in place, you can build the data endpoints. The Notes API handles creating, reading, updating, and deleting notes with category filtering. The Tasks API manages study reminders and schedules that will later feed into the BullMQ job system.
- Create the notes routes at server/src/routes/notes.ts with this code:
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import { authenticate, AuthRequest } from "../middleware/auth";
const prisma = new PrismaClient();
export const notesRouter = Router();
// All notes routes require authentication
notesRouter.use(authenticate);
// List notes with optional category filter and pagination
notesRouter.get("/", async (req: AuthRequest, res) => {
const { categoryId, search, page = "1", limit = "20" } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
const where: any = { authorId: req.userId };
if (categoryId) where.categoryId = categoryId;
if (search) where.title = { contains: search as string, mode: "insensitive" };
const notes = await prisma.note.findMany({
where,
include: { category: true, tags: true },
orderBy: { updatedAt: "desc" },
skip,
take: parseInt(limit as string),
});
res.json(notes);
});
// Create a new note
notesRouter.post("/", async (req: AuthRequest, res) => {
const { title, categoryId, isPublic } = req.body;
const note = await prisma.note.create({
data: {
title,
categoryId,
authorId: req.userId!,
isPublic: isPublic || false,
},
include: { category: true },
});
res.status(201).json(note);
});
// Get a single note by ID
notesRouter.get("/:id", async (req: AuthRequest, res) => {
const note = await prisma.note.findUnique({
where: { id: req.params.id },
include: { category: true, tags: true },
});
if (!note) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(note);
});
// Update a note
notesRouter.put("/:id", async (req: AuthRequest, res) => {
const { title, categoryId, isPublic } = req.body;
const note = await prisma.note.update({
where: { id: req.params.id },
data: { title, categoryId, isPublic },
include: { category: true },
});
res.json(note);
});
// Delete a note
notesRouter.delete("/:id", async (req: AuthRequest, res) => {
await prisma.note.delete({ where: { id: req.params.id } });
res.status(204).send();
});
What does this code do?
- The notesRouter.use(authenticate) line protects all note endpoints. Every request must include a valid JWT.
- The GET endpoint supports filtering by categoryId, case-insensitive title search, and offset-based pagination with page and limit query parameters.
- Prisma's include eagerly loads related Category and Tag records in a single query.
- Save notes.ts.
- Create the tasks routes at server/src/routes/tasks.ts with this code:
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import { authenticate, AuthRequest } from "../middleware/auth";
const prisma = new PrismaClient();
export const tasksRouter = Router();
// All task routes require authentication
tasksRouter.use(authenticate);
// List tasks for the current user
tasksRouter.get("/", async (req: AuthRequest, res) => {
const tasks = await prisma.task.findMany({
where: { userId: req.userId },
include: { schedule: true },
orderBy: { createdAt: "desc" },
});
res.json(tasks);
});
// Create a new task
tasksRouter.post("/", async (req: AuthRequest, res) => {
const { title, description, noteId, priority, dueAt } = req.body;
const task = await prisma.task.create({
data: {
title,
description,
userId: req.userId!,
noteId,
priority,
dueAt: dueAt ? new Date(dueAt) : null,
},
});
res.status(201).json(task);
});
// Update a task
tasksRouter.put("/:id", async (req: AuthRequest, res) => {
const { title, description, status, priority, dueAt } = req.body;
const task = await prisma.task.update({
where: { id: req.params.id },
data: {
title,
description,
status,
priority,
dueAt: dueAt ? new Date(dueAt) : null,
},
});
res.json(task);
});
// Delete a task
tasksRouter.delete("/:id", async (req: AuthRequest, res) => {
await prisma.task.delete({ where: { id: req.params.id } });
res.status(204).send();
});
// Attach a cron schedule to a task
tasksRouter.post("/:id/schedule", async (req: AuthRequest, res) => {
const { pattern, timezone } = req.body;
const schedule = await prisma.taskSchedule.create({
data: {
taskId: req.params.id,
pattern,
timezone: timezone || "UTC",
},
});
res.status(201).json(schedule);
});
What does this code do?
- Tasks are scoped to the authenticated user via req.userId.
- The POST /:id/schedule endpoint attaches a cron pattern to a task. This is the schedule metadata that BullMQ will consume in a later step.
- The dueAt field is converted from a string to a Date object, handling the case where it might not be provided.
- Save tasks.ts.
- Add a dev script to server/package.json so nodemon can start the server. Open server/package.json and add this to the "scripts" section:
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts"
}
- Save package.json.
- Start the server by running:
npm run dev
You should see API server running on port 3001 in the terminal.
- Test the registration endpoint by opening a new terminal tab and running:
curl -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","name":"Test User","password":"password123"}'
You should see a JSON response containing a token field and a user object with id, email, name, and color fields.
Server won't start?
- If you see "Cannot find module" errors, check that all files are saved in the correct directories (src/routes/ and src/middleware/).
- If you see a connection error to PostgreSQL, make sure your Docker containers are still running with docker compose ps.
- If the port is already in use, another process is running on 3001. Stop it or set PORT=3002 before running.
My Express server won't start and I'm seeing an error in the terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in the server/src/ directory.
ⓧ I'd like to double check the full code
Here is server/src/index.ts:
import express from "express";
import cors from "cors";
import { authRouter } from "./routes/auth";
import { notesRouter } from "./routes/notes";
import { tasksRouter } from "./routes/tasks";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.use(express.json());
app.use("/api/auth", authRouter);
app.use("/api/notes", notesRouter);
app.use("/api/tasks", tasksRouter);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`API server running on port ${PORT}`);
});
Here is server/src/middleware/auth.ts:
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "collabnotes-dev-secret";
export interface AuthRequest extends Request {
userId?: string;
}
export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid token" });
return;
}
const token = authHeader.split(" ")[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string };
req.userId = payload.userId;
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
export function signToken(userId: string): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: "7d" });
}
Here is server/src/middleware/errorHandler.ts:
import { Request, Response, NextFunction } from "express";
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
}
Here is server/src/routes/auth.ts:
import { Router } from "express";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";
import { signToken } from "../middleware/auth";
const prisma = new PrismaClient();
export const authRouter = Router();
authRouter.post("/register", async (req, res) => {
const { email, name, password } = req.body;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.status(409).json({ error: "Email already registered" });
return;
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { email, name, password: hashedPassword },
});
const token = signToken(user.id);
res.status(201).json({ token, user: { id: user.id, email: user.email, name: user.name, color: user.color } });
});
authRouter.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const token = signToken(user.id);
res.json({ token, user: { id: user.id, email: user.email, name: user.name, color: user.color } });
});
Here is server/src/routes/notes.ts:
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import { authenticate, AuthRequest } from "../middleware/auth";
const prisma = new PrismaClient();
export const notesRouter = Router();
notesRouter.use(authenticate);
notesRouter.get("/", async (req: AuthRequest, res) => {
const { categoryId, search, page = "1", limit = "20" } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
const where: any = { authorId: req.userId };
if (categoryId) where.categoryId = categoryId;
if (search) where.title = { contains: search as string, mode: "insensitive" };
const notes = await prisma.note.findMany({
where,
include: { category: true, tags: true },
orderBy: { updatedAt: "desc" },
skip,
take: parseInt(limit as string),
});
res.json(notes);
});
notesRouter.post("/", async (req: AuthRequest, res) => {
const { title, categoryId, isPublic } = req.body;
const note = await prisma.note.create({
data: {
title,
categoryId,
authorId: req.userId!,
isPublic: isPublic || false,
},
include: { category: true },
});
res.status(201).json(note);
});
notesRouter.get("/:id", async (req: AuthRequest, res) => {
const note = await prisma.note.findUnique({
where: { id: req.params.id },
include: { category: true, tags: true },
});
if (!note) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(note);
});
notesRouter.put("/:id", async (req: AuthRequest, res) => {
const { title, categoryId, isPublic } = req.body;
const note = await prisma.note.update({
where: { id: req.params.id },
data: { title, categoryId, isPublic },
include: { category: true },
});
res.json(note);
});
notesRouter.delete("/:id", async (req: AuthRequest, res) => {
await prisma.note.delete({ where: { id: req.params.id } });
res.status(204).send();
});
Here is server/src/routes/tasks.ts:
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import { authenticate, AuthRequest } from "../middleware/auth";
const prisma = new PrismaClient();
export const tasksRouter = Router();
tasksRouter.use(authenticate);
tasksRouter.get("/", async (req: AuthRequest, res) => {
const tasks = await prisma.task.findMany({
where: { userId: req.userId },
include: { schedule: true },
orderBy: { createdAt: "desc" },
});
res.json(tasks);
});
tasksRouter.post("/", async (req: AuthRequest, res) => {
const { title, description, noteId, priority, dueAt } = req.body;
const task = await prisma.task.create({
data: {
title,
description,
userId: req.userId!,
noteId,
priority,
dueAt: dueAt ? new Date(dueAt) : null,
},
});
res.status(201).json(task);
});
tasksRouter.put("/:id", async (req: AuthRequest, res) => {
const { title, description, status, priority, dueAt } = req.body;
const task = await prisma.task.update({
where: { id: req.params.id },
data: {
title,
description,
status,
priority,
dueAt: dueAt ? new Date(dueAt) : null,
},
});
res.json(task);
});
tasksRouter.delete("/:id", async (req: AuthRequest, res) => {
await prisma.task.delete({ where: { id: req.params.id } });
res.status(204).send();
});
tasksRouter.post("/:id/schedule", async (req: AuthRequest, res) => {
const { pattern, timezone } = req.body;
const schedule = await prisma.taskSchedule.create({
data: {
taskId: req.params.id,
pattern,
timezone: timezone || "UTC",
},
});
res.status(201).json(schedule);
});
Your REST API is live. You have authentication, note management, and task scheduling endpoints all protected by JWT tokens. Next up, you will build the real-time collaboration server that handles multi-user editing via WebSockets.
Build the Real-Time Collaboration Server
Your REST API handles authentication and CRUD operations, but CollabNotes needs something more. When two students open the same note, their edits need to merge instantly without conflicts.
This is where Hocuspocus comes in. It is a WebSocket server built specifically for Yjs document synchronization. In this step, you will set up a dedicated collaboration server that authenticates users via JWT, persists document state to PostgreSQL, and broadcasts real-time CRDT updates to every connected client.
In this step, get ready to:
- Set up the Hocuspocus WebSocket server with JWT authentication hooks.
- Configure document persistence to store Yjs updates in PostgreSQL.
- Verify presence awareness and connection lifecycle management.
Set up the Hocuspocus WebSocket server with authentication hooks
The collaboration server is a separate Node.js process from your Express API. This separation keeps real-time WebSocket traffic isolated from request-response HTTP traffic.
- Open a new terminal panel in VS Code.
- Navigate to the collabnotes project root, then create the collab-server directory and move into it by running these commands:
cd ~/Desktop/collabnotes
mkdir -p collab-server/src
cd collab-server
- Initialize a new Node.js project by running:
npm init -y
- Install the Hocuspocus server and database extension, plus Prisma Client and jsonwebtoken by running:
npm install @hocuspocus/server @hocuspocus/extension-database @prisma/client jsonwebtoken
- Install TypeScript dev dependencies by running:
npm install -D typescript @types/node @types/jsonwebtoken tsx
What do these packages do?
The @hocuspocus/server package provides the WebSocket server that manages Yjs document synchronization between connected clients.
The @hocuspocus/extension-database package is a generic persistence adapter that lets you plug in any database for storing document state.
The tsx package runs TypeScript files directly without a separate build step, which keeps development fast.
- Set up Prisma Client access by creating a symlink to the shared schema. Run:
ln -s ../server/prisma ./prisma
npx prisma generate
On Windows or symlink not working?
If the symlink fails, copy the prisma folder instead. Run cp -r ../server/prisma ./prisma (macOS/Linux) or xcopy ..\server\prisma .\prisma /E /I (Windows). Then run npx prisma generate to generate the client.
Help me set up Prisma Client sharing
- Create a .env file inside collab-server with the database connection string by running:
echo 'DATABASE_URL="postgresql://collabnotes:collabnotes_dev@localhost:5432/collabnotes"' > .env
- Confirm the directory structure looks correct by running:
ls
You should see node_modules, package.json, prisma, src, and .env in the output.
Now create the main server file. This is where Hocuspocus will authenticate WebSocket connections using the same JWT secret your Express API uses.
- Create collab-server/src/index.ts and paste the following code:
import { Hocuspocus } from "@hocuspocus/server";
import { Database } from "@hocuspocus/extension-database";
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || "collabnotes-dev-secret";
const server = new Hocuspocus({
port: 1234,
async onAuthenticate({ token }) {
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: payload.userId },
});
if (!user) throw new Error("User not found");
return { user: { id: user.id, name: user.name, color: user.color } };
} catch {
throw new Error("Authentication failed");
}
},
extensions: [
new Database({
async fetch({ documentName }) {
const result = await prisma.$queryRaw<{ data: Buffer }[]>`
SELECT data FROM "YjsDocument" WHERE name = ${documentName}
`;
return result.length > 0 ? result[0].data : null;
},
async store({ documentName, state }) {
await prisma.$executeRaw`
INSERT INTO "YjsDocument" (name, data, "updatedAt")
VALUES (${documentName}, ${state}, NOW())
ON CONFLICT (name) DO UPDATE SET data = ${state}, "updatedAt" = NOW()
`;
},
}),
],
});
server.listen();
console.log("Hocuspocus collaboration server running on port 1234");
How does the onAuthenticate hook work?
When a client opens a WebSocket connection, Hocuspocus fires the onAuthenticate hook before granting access to any document. The hook receives the token the client sends on connect.
Your hook verifies the JWT using the same secret (collabnotes-dev-secret) that the Express API uses to sign tokens. If verification succeeds, it looks up the user in PostgreSQL and returns their id, name, and color for presence display.
If the token is invalid or the user does not exist, the hook throws an error and Hocuspocus immediately closes the connection.
- Save collab-server/src/index.ts.
Seeing TypeScript import errors in your editor?
If your editor shows red squiggles under the imports, make sure you ran npx prisma generate inside the collab-server directory. The Prisma Client types are generated on demand.
Help me fix my import errors
Configure document persistence with the database extension
The Database extension you added in the extensions array handles the full persistence lifecycle. Every time a document changes, Hocuspocus calls store with the latest binary state. When a new client connects to an existing document, it calls fetch to load the last-known state.
Why raw SQL instead of Prisma models?
The YjsDocument table stores binary CRDT data (a bytea column). Prisma's ORM abstractions do not handle raw binary upserts cleanly because the Yjs state is a Uint8Array that needs to be inserted directly.
Using $queryRaw and $executeRaw with tagged template literals lets Prisma handle parameterized queries safely while passing binary data straight to PostgreSQL.
What is the fetch/store lifecycle?
- fetch: Runs when the first client connects to a document name. It looks up the YjsDocument row matching that name and returns the binary data. If no row exists, it returns null and Hocuspocus creates a fresh empty document.
- store: Runs periodically (debounced) as clients make changes. It upserts the full document state using ON CONFLICT so existing documents get updated while new ones get created.
- Add a dev script to your collab-server/package.json so you can start the server easily. Open collab-server/package.json and find the "scripts" section.
- Replace the existing "test" script line with:
"scripts": {
"dev": "tsx --env-file=.env src/index.ts"
}
What does this script do?
The tsx command runs TypeScript directly without compiling first. The --env-file=.env flag loads environment variables (like DATABASE_URL) from the .env file before executing the server.
- Save collab-server/package.json.
Verify presence awareness and connection lifecycle management
Presence awareness means each connected user can see other users' cursors and text selections in real time. Hocuspocus handles this automatically through the Yjs awareness protocol.
How does presence work without extra code?
Yjs includes an awareness protocol that piggybacks on the same WebSocket connection used for document sync. Each client broadcasts its cursor position, selection range, and user metadata (name and color) to all other clients connected to the same document.
Your onAuthenticate hook returns { user: { id, name, color } } on successful auth. Hocuspocus passes this user object to the awareness layer automatically. When the frontend connects with a collaboration cursor extension, each user sees colored cursors labeled with names.
- Make sure your Docker containers are running. In a terminal at the project root, check with:
docker compose ps
You should see both postgres and redis containers with status Up.
- Start the collaboration server from the collab-server directory by running:
npm run dev
You should see Hocuspocus collaboration server running on port 1234 in your terminal output.
- Test the HTTP endpoint by opening a separate terminal and running:
curl http://localhost:1234
You should see a plain text OK response. This confirms Hocuspocus is listening and ready to accept WebSocket connections.
Server crashes on startup?
If you see a connection error mentioning PostgreSQL, verify your Docker containers are running with docker compose ps. The DATABASE_URL in your .env must match the credentials in docker-compose.yml.
If you see a module resolution error, make sure you ran npx prisma generate inside the collab-server directory after setting up the Prisma symlink.
Help me debug my server startup error
What about connection lifecycle?
Hocuspocus manages the full WebSocket lifecycle for you. When a client disconnects (closes the tab or loses network), the server automatically removes them from the document's awareness state. Other clients see the cursor disappear instantly.
If all clients disconnect from a document, Hocuspocus calls store one final time to persist the latest state, then frees the document from memory.
✔️ Awesome, I've got everything!
Great. Double check you have saved collab-server/src/index.ts and that the server starts without errors.
ⓧ I'd like to double check the full code
Here is the complete collab-server/src/index.ts file:
import { Hocuspocus } from "@hocuspocus/server";
import { Database } from "@hocuspocus/extension-database";
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || "collabnotes-dev-secret";
const server = new Hocuspocus({
port: 1234,
async onAuthenticate({ token }) {
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: payload.userId },
});
if (!user) throw new Error("User not found");
return { user: { id: user.id, name: user.name, color: user.color } };
} catch {
throw new Error("Authentication failed");
}
},
extensions: [
new Database({
async fetch({ documentName }) {
const result = await prisma.$queryRaw<{ data: Buffer }[]>`
SELECT data FROM "YjsDocument" WHERE name = ${documentName}
`;
return result.length > 0 ? result[0].data : null;
},
async store({ documentName, state }) {
await prisma.$executeRaw`
INSERT INTO "YjsDocument" (name, data, "updatedAt")
VALUES (${documentName}, ${state}, NOW())
ON CONFLICT (name) DO UPDATE SET data = ${state}, "updatedAt" = NOW()
`;
},
}),
],
});
server.listen();
console.log("Hocuspocus collaboration server running on port 1234");
Your collaboration server is live. It authenticates users, persists their edits to PostgreSQL, and broadcasts presence data to every connected client. Next up, you will build the React frontend with the Tiptap editor that connects to this server for real-time collaborative editing.
Build the React Frontend with Tiptap
Your backend services are ready. The Express API handles authentication and note CRUD, and the Hocuspocus server manages real-time document synchronization.
Now you need a frontend that lets users log in, browse notes, and collaboratively edit documents in real time. You will build a React SPA with Tiptap as the rich-text editor, connected to Hocuspocus via Yjs for conflict-free multi-user editing.
In this step, get ready to:
- Scaffold the React + Vite + TypeScript frontend and install Tiptap with collaboration extensions.
- Build the collaborative editor component with Yjs and HocuspocusProvider for real-time sync.
- Implement the full app with login, notes dashboard, and live cursor support.
Scaffold the React frontend with Vite
The frontend is a separate service in your monorepo. You will scaffold it with Vite using the React TypeScript template, then install the Tiptap editor packages and the Hocuspocus provider for WebSocket-based collaboration.
- Open a new terminal tab and navigate to your collabnotes directory.
- Scaffold the React frontend by running this command:
npm create vite@latest client -- --template react-ts
What does this command do?
This uses Vite's project scaffolder to create a client directory with React, TypeScript, and a fast dev server pre-configured.
The --template react-ts flag selects the React + TypeScript starter template.
- Move into the client directory and install the base dependencies by running:
cd client
npm install
- Install the Tiptap editor and collaboration packages by running:
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor @hocuspocus/provider yjs
What are these packages?
- @tiptap/react and @tiptap/starter-kit provide the rich-text editor with common extensions (bold, italic, headings, lists).
- @tiptap/extension-collaboration connects Tiptap to a Yjs document for real-time sync.
- @tiptap/extension-collaboration-cursor shows other users' live cursors and text selections.
- @hocuspocus/provider is the WebSocket client that connects your Y.Doc to the Hocuspocus server.
- yjs is the CRDT library that powers conflict-free editing.
- Confirm the packages installed by running:
ls node_modules/@tiptap
You should see folders for react, starter-kit, extension-collaboration, and extension-collaboration-cursor in the output.
Seeing peer dependency warnings?
Tiptap's collaboration packages sometimes show peer dependency warnings about y-prosemirror versions. These warnings are non-blocking. If the install completes, your packages are ready to use.
If the install fails entirely, try running npm install --legacy-peer-deps to bypass strict peer dependency resolution.
Help me fix Tiptap collaboration package installation errors.
Build the collaborative editor component
The editor component is the heart of CollabNotes. It creates a Yjs document, connects to the Hocuspocus server over WebSocket, and renders a rich-text editor that syncs edits across all connected clients in real time.
- Create the components directory inside client/src by running:
mkdir src/components
- Create a new file at src/components/CollaborativeEditor.tsx and add the following imports and interface:
import { useEffect, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
interface CollaborativeEditorProps {
noteId: string;
token: string;
userName: string;
userColor: string;
}
What does this code do?
- The imports bring in React hooks, Tiptap's React bindings, the StarterKit (basic formatting), and the two collaboration extensions.
- HocuspocusProvider is the WebSocket client that connects a Y.Doc to your Hocuspocus server on port 1234.
- CollaborativeEditorProps defines what the parent component must pass in: which note to open, the JWT token for auth, and user identity for live cursors.
- Below the interface, add the component function with the WebSocket connection logic:
export function CollaborativeEditor({
noteId,
token,
userName,
userColor,
}: CollaborativeEditorProps) {
const [provider, setProvider] = useState<HocuspocusProvider | null>(null);
const [ydoc] = useState(() => new Y.Doc());
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
useEffect(() => {
const hocuspocusProvider = new HocuspocusProvider({
url: "ws://localhost:1234",
name: noteId,
document: ydoc,
token,
});
hocuspocusProvider.on("status", ({ status }: { status: string }) => {
setStatus(status as "connecting" | "connected" | "disconnected");
});
setProvider(hocuspocusProvider);
return () => {
hocuspocusProvider.destroy();
};
}, [noteId, token, ydoc]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
history: false,
}),
Collaboration.configure({
document: ydoc,
}),
...(provider
? [
CollaborationCursor.configure({
provider,
user: { name: userName, color: userColor },
}),
]
: []),
],
},
[provider]
);
return (
<div className="collaborative-editor">
<div className="editor-status">
<span className={`status-dot status-${status}`} />
{status}
</div>
<EditorContent editor={editor} />
</div>
);
}
What does this code do?
- useState(() => new Y.Doc()) creates a single Yjs document instance that persists across re-renders. This is the local CRDT state.
- The useEffect creates a HocuspocusProvider that opens a WebSocket to ws://localhost:1234, passing the note ID as the document name and the JWT for authentication.
- StarterKit.configure({ history: false }) disables the built-in undo/redo because the Collaboration extension provides its own CRDT-aware history.
- CollaborationCursor broadcasts the current user's cursor position and renders remote users' cursors with their name and color.
- The cleanup function calls hocuspocusProvider.destroy() to close the WebSocket when the component unmounts.
Why disable history in StarterKit?
Tiptap's StarterKit includes a built-in undo/redo system based on ProseMirror transactions. The Collaboration extension replaces this with Yjs-aware undo/redo that understands distributed edits.
If both are active, they conflict and produce unexpected undo behaviour. Setting history: false prevents that conflict.
- Save src/components/CollaborativeEditor.tsx.
✔️ Awesome, I've got everything!
Great. Double check that you have saved the file.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
interface CollaborativeEditorProps {
noteId: string;
token: string;
userName: string;
userColor: string;
}
export function CollaborativeEditor({
noteId,
token,
userName,
userColor,
}: CollaborativeEditorProps) {
const [provider, setProvider] = useState<HocuspocusProvider | null>(null);
const [ydoc] = useState(() => new Y.Doc());
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
useEffect(() => {
const hocuspocusProvider = new HocuspocusProvider({
url: "ws://localhost:1234",
name: noteId,
document: ydoc,
token,
});
hocuspocusProvider.on("status", ({ status }: { status: string }) => {
setStatus(status as "connecting" | "connected" | "disconnected");
});
setProvider(hocuspocusProvider);
return () => {
hocuspocusProvider.destroy();
};
}, [noteId, token, ydoc]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
history: false,
}),
Collaboration.configure({
document: ydoc,
}),
...(provider
? [
CollaborationCursor.configure({
provider,
user: { name: userName, color: userColor },
}),
]
: []),
],
},
[provider]
);
return (
<div className="collaborative-editor">
<div className="editor-status">
<span className={`status-dot status-${status}`} />
{status}
</div>
<EditorContent editor={editor} />
</div>
);
}
Build the app with login, dashboard, and editor routing
The main App component manages authentication state and routes between three views: login, notes dashboard, and the collaborative editor. This gives users a complete flow from sign-in to real-time editing.
- Open src/App.tsx in your editor. Replace the entire file contents with the following imports and interfaces:
import { useState } from "react";
import { CollaborativeEditor } from "./components/CollaborativeEditor";
interface User {
id: string;
name: string;
email: string;
color: string;
}
What does this code do?
The User interface matches the shape returned by your /api/auth/login endpoint. The import of CollaborativeEditor connects this file to the component you just built.
- Below the imports, add the main App function that handles routing between views:
function App() {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
if (!token || !user) {
return <LoginForm onLogin={(t, u) => { setToken(t); setUser(u); }} />;
}
if (!selectedNoteId) {
return <NotesDashboard token={token} onSelectNote={setSelectedNoteId} />;
}
return (
<div className="app">
<header>
<button onClick={() => setSelectedNoteId(null)}>Back to Notes</button>
<span>Editing as {user.name}</span>
</header>
<CollaborativeEditor
noteId={selectedNoteId}
token={token}
userName={user.name}
userColor={user.color}
/>
</div>
);
}
What does this code do?
- Three pieces of state control which view is shown: token (null = not logged in), user (user profile), and selectedNoteId (which note is open).
- If no token exists, the login form is shown. Once logged in, the dashboard appears. Clicking a note opens the collaborative editor.
- The CollaborativeEditor receives the selected note ID, JWT token, and user details for cursor rendering.
- Below the App function, add the LoginForm component:
function LoginForm({ onLogin }: { onLogin: (token: string, user: User) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("http://localhost:3001/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (res.ok) onLogin(data.token, data.user);
}
return (
<form onSubmit={handleSubmit}>
<h1>CollabNotes</h1>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" type="password" />
<button type="submit">Login</button>
</form>
);
}
What does this code do?
The LoginForm posts credentials to your Express API's /api/auth/login endpoint. On success, the response contains a JWT token and user object. The onLogin callback passes these up to the parent App component, which triggers the view switch to the dashboard.
- Below the LoginForm, add the NotesDashboard component and the default export:
function NotesDashboard({ token, onSelectNote }: { token: string; onSelectNote: (id: string) => void }) {
const [notes, setNotes] = useState<{ id: string; title: string; category: { name: string } }[]>([]);
async function fetchNotes() {
const res = await fetch("http://localhost:3001/api/notes", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setNotes(data);
}
useState(() => { fetchNotes(); });
return (
<div className="dashboard">
<h1>My Notes</h1>
<div className="notes-grid">
{notes.map((note) => (
<div key={note.id} className="note-card" onClick={() => onSelectNote(note.id)}>
<h3>{note.title}</h3>
<span className="category-badge">{note.category.name}</span>
</div>
))}
</div>
</div>
);
}
export default App;
What does this code do?
- NotesDashboard fetches all notes from GET /api/notes using the JWT in the Authorization header.
- Each note renders as a clickable card showing the title and category. Clicking a card calls onSelectNote with the note ID, which triggers the editor view.
- export default App makes the component importable by Vite's entry point.
- Save src/App.tsx.
- Start the frontend dev server by running:
npm run dev
You should see output indicating the dev server is running on http://localhost:5173.
- Open http://localhost:5173 in your browser. You should see the CollabNotes login form with email and password fields.
Don't see the login form?
Make sure you saved both src/App.tsx and src/components/CollaborativeEditor.tsx. Vite's hot reload picks up changes on save.
Check the terminal for TypeScript errors. A missing import or typo in the interface will prevent the page from rendering.
Help me debug why my Vite React app isn't showing the login form.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all files.
ⓧ I'd like to double check the full code
import { useState } from "react";
import { CollaborativeEditor } from "./components/CollaborativeEditor";
interface User {
id: string;
name: string;
email: string;
color: string;
}
function App() {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
if (!token || !user) {
return <LoginForm onLogin={(t, u) => { setToken(t); setUser(u); }} />;
}
if (!selectedNoteId) {
return <NotesDashboard token={token} onSelectNote={setSelectedNoteId} />;
}
return (
<div className="app">
<header>
<button onClick={() => setSelectedNoteId(null)}>Back to Notes</button>
<span>Editing as {user.name}</span>
</header>
<CollaborativeEditor
noteId={selectedNoteId}
token={token}
userName={user.name}
userColor={user.color}
/>
</div>
);
}
function LoginForm({ onLogin }: { onLogin: (token: string, user: User) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("http://localhost:3001/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (res.ok) onLogin(data.token, data.user);
}
return (
<form onSubmit={handleSubmit}>
<h1>CollabNotes</h1>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" type="password" />
<button type="submit">Login</button>
</form>
);
}
function NotesDashboard({ token, onSelectNote }: { token: string; onSelectNote: (id: string) => void }) {
const [notes, setNotes] = useState<{ id: string; title: string; category: { name: string } }[]>([]);
async function fetchNotes() {
const res = await fetch("http://localhost:3001/api/notes", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setNotes(data);
}
useState(() => { fetchNotes(); });
return (
<div className="dashboard">
<h1>My Notes</h1>
<div className="notes-grid">
{notes.map((note) => (
<div key={note.id} className="note-card" onClick={() => onSelectNote(note.id)}>
<h3>{note.title}</h3>
<span className="category-badge">{note.category.name}</span>
</div>
))}
</div>
</div>
);
}
export default App;
Your frontend is live. Next up, you will build the task scheduling system with BullMQ to add study reminders and spaced repetition to the platform.
Build the Task Scheduling System with BullMQ
Your collaborative editor handles real-time document sync, but a study platform needs more than just editing. Students need reminders, spaced repetition schedules, and background processing that runs independently of user sessions.
In this step, you will build a dedicated task scheduling system using BullMQ and Redis. This runs as its own process, separate from the API server, demonstrating how production systems isolate background work from request handling.
In this step, get ready to:
- Set up BullMQ queues and workers as a separate process with Redis.
- Implement study reminder, spaced repetition, and note summary job types.
- Add retry logic with exponential backoff and a REST endpoint for schedule management.
Set up BullMQ queues and workers as a separate process
Task queues decouple time-consuming operations from the request-response cycle. Your worker process connects to the same Redis instance that your collaboration server uses, but it operates independently.
Why BullMQ over node-cron?
BullMQ persists jobs in Redis, meaning scheduled tasks survive server restarts. It also supports distributed workers, rate limiting, and parent-child job flows.
Since your project already runs Redis for Hocuspocus document persistence, BullMQ avoids introducing a second persistence layer (unlike Agenda.js which requires MongoDB).
- Open a new terminal and navigate to your collabnotes project root.
- Create the worker directory and initialize it by running these commands:
mkdir -p worker/src
cd worker
npm init -y
npm i bullmq ioredis
npm i -D typescript @types/node ts-node
What do these packages do?
- bullmq provides the Queue and Worker classes for job processing.
- ioredis is the Redis client that BullMQ uses internally for its connection.
- ts-node lets you run TypeScript files directly without a separate compile step.
- Confirm the packages installed by running:
ls node_modules | grep bullmq
You should see bullmq in the output.
Installation failed?
Make sure your Docker containers are still running with docker compose ps from the project root. The worker needs Redis available at port 6379.
help me debug my BullMQ installation
- Create the worker entry point by adding this code to worker/src/index.ts:
import { Worker, Queue } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: null,
});
const taskQueue = new Queue("tasks", { connection });
Why maxRetriesPerRequest: null?
BullMQ workers use blocking Redis commands (like BRPOPLPUSH) that wait indefinitely for new jobs. By default, ioredis retries failed requests a limited number of times and then throws.
Setting maxRetriesPerRequest: null tells ioredis to never give up on blocking commands, preventing the worker from stalling.
- Save worker/src/index.ts.
You will not see output yet because the worker is not complete. The next chunk adds the job processor.
Implement three job types with cron scheduling
The worker needs to handle different job types through a single queue. Each job arrives with a name property that determines how it gets processed.
- In worker/src/index.ts, add the Worker definition below the taskQueue line:
// Study reminder worker
const reminderWorker = new Worker(
"tasks",
async (job) => {
switch (job.name) {
case "study-reminder":
console.log(`[Reminder] ${job.data.title} for user ${job.data.userId}`);
// In production: send push notification or email
break;
case "spaced-repetition":
console.log(`[Spaced Rep] Review note ${job.data.noteId}`);
break;
case "note-summary":
console.log(`[Summary] Generating summary for note ${job.data.noteId}`);
break;
default:
throw new Error(`Unknown job type: ${job.name}`);
}
},
{
connection,
concurrency: 5,
}
);
What does this code do?
- The Worker listens on the "tasks" queue and picks up jobs as they arrive.
- The switch statement routes each job to the correct handler based on job.name.
- concurrency: 5 allows 5 jobs to process simultaneously, keeping throughput high without overwhelming the system.
- Unknown job types throw an error, which moves the job to the failed set for investigation.
- Next, add the event listeners and the cron scheduler below the Worker definition:
reminderWorker.on("completed", (job) => {
console.log(`Job ${job.id} completed successfully`);
});
reminderWorker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed: ${err.message}`);
});
// Set up recurring job schedulers
async function setupSchedulers() {
await taskQueue.upsertJobScheduler(
"daily-review-reminder",
{
pattern: "0 0 9 * * *",
tz: "UTC",
},
{
name: "spaced-repetition",
data: { type: "daily-review" },
}
);
console.log("Job schedulers initialized");
}
setupSchedulers();
console.log("Task worker running...");
What does this code do?
- The completed and failed event listeners log job outcomes for observability.
- upsertJobScheduler creates a cron-based recurring job that fires every day at 9:00 AM UTC. The upsert part means it is safe to re-run on every deploy without creating duplicates.
- The cron pattern 0 0 9 * * * means: second 0, minute 0, hour 9, every day, every month, every day of week.
Why upsertJobScheduler instead of the old repeat API?
BullMQ deprecated the repeatable job APIs in version 5.16.0. The new Job Schedulers API is idempotent by scheduler name, meaning multiple deployments never accidentally create duplicate schedules.
- Save worker/src/index.ts.
- From the worker directory, start the worker by running:
npx ts-node src/index.ts
You should see two lines in your terminal: Task worker running... followed by Job schedulers initialized.
Seeing a connection error?
Make sure your Redis container is running. From the project root, run docker compose ps and confirm the redis service shows as healthy on port 6379.
If you see a TypeScript compilation error, check that you installed the dev dependencies (typescript, @types/node, ts-node).
help me fix my BullMQ worker connection
✔️ Awesome, I've got everything!
Great. Double check you have saved worker/src/index.ts and that the worker is running without errors.
ⓧ I'd like to double check the full code
import { Worker, Queue } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: null,
});
const taskQueue = new Queue("tasks", { connection });
// Study reminder worker
const reminderWorker = new Worker(
"tasks",
async (job) => {
switch (job.name) {
case "study-reminder":
console.log(`[Reminder] ${job.data.title} for user ${job.data.userId}`);
// In production: send push notification or email
break;
case "spaced-repetition":
console.log(`[Spaced Rep] Review note ${job.data.noteId}`);
break;
case "note-summary":
console.log(`[Summary] Generating summary for note ${job.data.noteId}`);
break;
default:
throw new Error(`Unknown job type: ${job.name}`);
}
},
{
connection,
concurrency: 5,
}
);
reminderWorker.on("completed", (job) => {
console.log(`Job ${job.id} completed successfully`);
});
reminderWorker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed: ${err.message}`);
});
// Set up recurring job schedulers
async function setupSchedulers() {
await taskQueue.upsertJobScheduler(
"daily-review-reminder",
{
pattern: "0 0 9 * * *",
tz: "UTC",
},
{
name: "spaced-repetition",
data: { type: "daily-review" },
}
);
console.log("Job schedulers initialized");
}
setupSchedulers();
console.log("Task worker running...");
Add retry logic and the REST endpoint for schedule management
Production task queues need resilience. When a job fails (network timeout, external API down), you want automatic retries with increasing delays between attempts.
What is exponential backoff?
Exponential backoff increases the wait time between retries using the formula: 2^(attempts-1) * delay milliseconds. This prevents a failed service from being hammered with retry requests.
For example, with a 1000ms base delay: the first retry waits 1 second, the second waits 2 seconds, the third waits 4 seconds. Jobs that exhaust all retry attempts land in the failed set, acting as a dead-letter queue for investigation.
Retry options are configured per-job when adding to the queue. You will add a REST endpoint to your Express server that creates scheduled tasks with retry logic built in.
- In your server/src/routes/tasks.ts file, add the following endpoint for creating task schedules with retry configuration:
import { Queue } from "bullmq";
import IORedis from "ioredis";
// Redis connection for the task queue
const connection = new IORedis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: null,
});
const taskQueue = new Queue("tasks", { connection });
// POST /api/tasks/:id/schedule - Create a scheduled task with retry logic
tasksRouter.post("/:id/schedule", authenticate, async (req: AuthRequest, res) => {
const { id } = req.params;
const { pattern, timezone, delayMs } = req.body;
const task = await prisma.task.findUnique({ where: { id } });
if (!task || task.userId !== req.userId) {
res.status(404).json({ error: "Task not found" });
return;
}
if (pattern) {
// Cron-based recurring schedule
await taskQueue.upsertJobScheduler(
`task-${id}-schedule`,
{ pattern, tz: timezone || "UTC" },
{
name: "study-reminder",
data: { taskId: id, title: task.title, userId: req.userId },
opts: {
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
},
}
);
} else if (delayMs) {
// One-time delayed job
await taskQueue.add(
"study-reminder",
{ taskId: id, title: task.title, userId: req.userId },
{
delay: delayMs,
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
}
);
}
// Persist the schedule in the database
await prisma.taskSchedule.upsert({
where: { taskId: id },
update: { pattern: pattern || "", timezone: timezone || "UTC" },
create: { taskId: id, pattern: pattern || "", timezone: timezone || "UTC" },
});
res.json({ message: "Schedule created", taskId: id });
});
What does this endpoint do?
- It accepts either a cron pattern (for recurring jobs) or a delayMs value (for one-time delayed jobs).
- Every job gets attempts: 3 with backoff: { type: "exponential", delay: 1000 }, meaning 3 retry attempts with exponential backoff starting at 1 second.
- The schedule metadata is persisted in the TaskSchedule table so the UI can display active schedules.
- Failed jobs that exhaust all 3 attempts remain in the BullMQ failed set. This acts as a dead-letter queue that you can inspect and manually retry.
- Save server/src/routes/tasks.ts.
- Test the endpoint using curl from a new terminal. First, log in to get a token:
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password123"}'
- Copy the token value from the response and use it to create a delayed task (replace YOUR_TOKEN and YOUR_TASK_ID with actual values from your database):
curl -X POST http://localhost:3001/api/tasks/YOUR_TASK_ID/schedule \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"delayMs": 5000}'
After 5 seconds, switch to the terminal running your worker. You should see the [Reminder] log followed by a completed successfully message.
Not seeing the job process?
Make sure both your Express server (port 3001) and your worker process are running in separate terminals. They both need to be active for end-to-end job flow.
Check that the task ID exists in your database. You need to have created a task through your API before you can schedule it.
help me debug my BullMQ job scheduling
Extra for Experts: What about dead-letter queues?
In BullMQ, the failed set IS your dead-letter queue. After a job exhausts all retry attempts, it lands in the failed set where you can inspect it with taskQueue.getFailed() and manually retry with job.retry().
This is architecturally simpler than systems like AWS SQS where you must configure a separate dead-letter queue resource. BullMQ handles it transparently.
Your task scheduling system is complete. Next up, you will wire the entire frontend to all backend services and prepare your architecture documentation for interviews.
Integrate, Test, and Document the Architecture
You have all four services built and running independently. The Express API handles authentication and CRUD. The Hocuspocus server manages real-time document sync. The React frontend connects users to both. The BullMQ worker processes background tasks.
Individual services passing their own tests does not prove the system works as a whole. In this step, you will verify the end-to-end user flow, write interview-ready architecture documentation, and load test the collaboration server to confirm that CRDT conflict resolution holds under concurrent connections.
In this step, get ready to:
- Verify the full user flow from registration through real-time collaboration and task scheduling.
- Write system architecture documentation with data flow diagrams, tech stack justification, and interview Q&As.
- Load test the collaboration server with multiple concurrent WebSocket connections.
Verify the full user flow end-to-end
Before documenting the architecture, you need to confirm the system actually works. You will exercise every integration point: register, log in, create a note, collaborate in real time, and schedule a task.
- Make sure all four services are running: the React dev server on port 5173, the Express API on port 3001, the Hocuspocus server on port 1234, and the BullMQ worker process.
- Register a test user by running this command:
curl -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "alice@test.com", "name": "Alice", "password": "password123"}'
What does this command do?
This sends a POST request to your registration endpoint. The server hashes the password with bcrypt and creates a new user in PostgreSQL. You should receive a JSON response containing a token and a user object.
- Copy the token value from the response. You will use it in the next commands.
- Create a note by running this command (replace YOUR_TOKEN_HERE with the token you copied, and CATEGORY_ID_HERE with one of your seeded category IDs):
curl -X POST http://localhost:3001/api/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"title": "Binary Search Trees", "categoryId": "CATEGORY_ID_HERE"}'
Find your category IDs
If you do not know your seeded category IDs, query them directly from the database. Run docker exec -it collabnotes-postgres-1 psql -U collabnotes -c "SELECT id, name FROM \"Category\"" to list them.
- Copy the note id from the response.
- Open your browser and navigate to http://localhost:5173.
- Log in with the credentials you just registered (alice@test.com / password123).
- Click on the note you created. You should see the Tiptap editor load with a green connected status dot.
- Open a second browser tab (or an incognito window) and navigate to http://localhost:5173.
- Register a second user (different email) and open the same note.
- Type in the first tab. You should see the text appear in the second tab in real time, along with a colored cursor showing the other user's name.
Not seeing real-time sync?
Check that the Hocuspocus server terminal shows incoming connections. If you see authentication errors, make sure both browser tabs are logged in with valid tokens.
If the editor loads but changes do not sync, confirm that both tabs are opening the same noteId. The Hocuspocus server uses the note ID as the document name for room isolation.
Help me debug why my real-time collaboration is not syncing between tabs.
- Schedule a task reminder by running this command (replace YOUR_TOKEN_HERE with Alice's token):
curl -X POST http://localhost:3001/api/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"title": "Review BST insertion", "description": "Practice balanced tree rotations"}'
- Check the worker terminal. You should see a log confirming the task was created.
What did you just verify?
You exercised the full data flow across all four services. The browser talks to Express over HTTP for auth and CRUD. The browser connects to Hocuspocus over WebSocket for real-time editing. The BullMQ worker picks up task jobs from Redis. Every integration point is confirmed working.
Write the system architecture documentation
Documentation turns your working code into an interview asset. You will create a markdown file that covers data flow, technology justifications, and prepared answers for the hardest pushback questions interviewers ask about distributed systems.
- Create a docs directory in the root of your collabnotes project by running:
mkdir docs
- Create the architecture documentation file by running:
touch docs/ARCHITECTURE.md
- Open docs/ARCHITECTURE.md in your editor and paste the following content:
# CollabNotes System Architecture
## Data Flow Diagram
```
Browser (React SPA)
|
|-- HTTP/REST --> Express API Server (port 3001)
| |
| |--> PostgreSQL (users, notes, categories, tasks)
| |--> Redis (task queue via BullMQ)
|
|-- WebSocket --> Hocuspocus Server (port 1234)
|
|--> PostgreSQL (Yjs document state as binary)
|--> In-memory (active Y.Doc instances)
BullMQ Worker Process
|
|-- Polls --> Redis (job queue: tasks)
|-- Reads --> PostgreSQL (task metadata)
```
### Flow: User Types in Editor
1. Keystroke updates the local Y.Doc (CRDT)
2. Yjs encodes the update as a binary diff
3. HocuspocusProvider sends the diff over WebSocket to Hocuspocus
4. Hocuspocus broadcasts the diff to all other connected clients
5. Hocuspocus debounces and persists the merged state to PostgreSQL
6. Other clients apply the diff to their local Y.Doc
7. Tiptap re-renders the editor content
### Flow: Task Scheduling
1. User creates a task via POST /api/tasks
2. Express validates the JWT, writes task to PostgreSQL
3. If a schedule is attached, Express adds a job to the BullMQ queue in Redis
4. Worker picks up the job, processes it (send reminder, generate summary)
5. On failure: exponential backoff retry (2^(attempts-1) * delay ms)
6. After max retries: job moves to dead-letter queue
## Tech Stack Justification
| Decision | Chosen | Alternative | Why |
|----------|--------|-------------|-----|
| Real-time sync | Yjs (CRDT) | ShareDB (OT) | CRDTs work without a central server for conflict resolution. Updates are commutative, associative, and idempotent. Enables offline-first architecture. |
| Task queue | BullMQ + Redis | node-cron / Agenda.js | BullMQ persists jobs in Redis (survives restarts), supports distributed locking, and we already use Redis for Hocuspocus. node-cron is in-memory only. |
| Database | PostgreSQL | MongoDB | Our data is inherently relational (users -> notes -> categories, tasks -> schedules). PostgreSQL enforces referential integrity. Yjs binary state stores in bytea columns. |
| Collab server | Hocuspocus | Custom y-websocket | Hocuspocus provides auth hooks, database persistence extensions, and webhook support out of the box. Custom y-websocket would require reimplementing all of this. |
| Frontend architecture | Separate React SPA + Express | Next.js monolith | Next.js Route Handlers cannot upgrade to WebSocket. Separate services demonstrate clear 3-tier architecture for interviewers. |
## Interview Pushback Q&As
### Q1: "Why CRDTs over OT? OT is battle-tested in Google Docs."
**Strategic answer:**
OT requires a central transformation server that sees every operation in order.
This creates a single point of failure and makes offline editing architecturally
complex (you need to queue and rebase operations on reconnect).
Yjs implements the YATA CRDT, which uses a linked-list model. Updates are
commutative, associative, and idempotent, meaning they can arrive in any order
and still converge to the same state. This makes offline-first trivial: just
store updates locally and merge on reconnect.
The trade-off: CRDTs use more memory than OT (they never garbage-collect
tombstones in the default mode). For a note-taking app with documents under
100KB, this is negligible. For a 500-page collaborative novel, you would need
periodic state snapshots.
### Q2: "Redis is a single point of failure. What happens when it goes down?"
**Strategic answer:**
In this architecture, Redis serves two purposes: BullMQ job persistence and
Hocuspocus horizontal scaling (Pub/Sub for multi-server setups).
If Redis goes down:
- BullMQ workers stall (they block-poll with BRPOPLPUSH). No new jobs process,
but no data is lost because PostgreSQL holds the source of truth for tasks.
- Hocuspocus continues working for single-server deployments because Y.Doc state
lives in memory and persists to PostgreSQL. Multi-server broadcasting pauses.
Mitigation in production: Redis Sentinel or Redis Cluster provides automatic
failover. BullMQ's maxRetriesPerRequest: null config prevents workers from
throwing on temporary Redis disconnects. The system degrades gracefully rather
than crashing.
### Q3: "How would you horizontally scale Hocuspocus?"
**Strategic answer:**
Hocuspocus documents are stateful (the Y.Doc lives in server memory while
clients are connected). Two scaling strategies:
**Option A: Sticky sessions with Redis Pub/Sub.**
Route all connections for a given document to the same Hocuspocus instance using
consistent hashing (e.g., hash(documentName) % serverCount). Use Redis Pub/Sub
to broadcast awareness updates across instances for the dashboard.
**Option B: Sharding by document.**
Assign each document to exactly one Hocuspocus instance using a registry
(etcd, Consul, or a Redis hash). The load balancer queries the registry and
routes the WebSocket upgrade to the correct shard. On instance failure, another
instance loads the document from PostgreSQL and takes over.
Both options require the Database extension (which we already have) so any
instance can reconstruct document state from PostgreSQL on failover.
Why write this documentation?
In a Staff Engineer interview, you will not be asked to live-code a CRDT. You will be asked to explain your design decisions and defend them against pushback. This document gives you rehearsed, strategic answers for the three hardest questions interviewers ask about collaborative systems.
The tech stack justification table is also valuable on your resume. It shows you evaluated alternatives and made deliberate choices, not just followed a tutorial.
- Save docs/ARCHITECTURE.md.
- Read through each section and customize the answers with your own wording. The content above is a starting template. The best interview answers sound natural, not memorized.
Load test the collaboration server with concurrent connections
A real-time system that works with two users might fall apart under ten. You will write a load test script that connects multiple Yjs clients to the same document simultaneously. Each client will make edits, and you will verify that all documents converge to the same state.
- Install the ws package in your project root (it provides a WebSocket polyfill for Node.js) by running:
npm install --save-dev ws y-websocket
- Create the load test script by running:
touch load-test.mjs
- Open load-test.mjs and paste the following code:
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import ws from "ws";
// Configuration
const NUM_CLIENTS = 10;
const DOCUMENT_NAME = "load-test-doc";
const SERVER_URL = "ws://localhost:1234";
const EDITS_PER_CLIENT = 20;
const clients = [];
async function createClient(index) {
const ydoc = new Y.Doc();
// Connect to Hocuspocus using y-websocket with ws polyfill
const provider = new WebsocketProvider(
SERVER_URL,
DOCUMENT_NAME,
ydoc,
{ WebSocketPolyfill: ws }
);
return new Promise((resolve) => {
provider.on("sync", (isSynced) => {
if (isSynced) {
console.log(`[Client ${index}] Connected and synced`);
// Each client inserts text at random positions
const ytext = ydoc.getText("default");
for (let i = 0; i < EDITS_PER_CLIENT; i++) {
const pos = Math.floor(Math.random() * (ytext.length + 1));
ytext.insert(pos, `C${index}E${i} `);
}
console.log(`[Client ${index}] Inserted ${EDITS_PER_CLIENT} edits`);
resolve({ ydoc, provider, index });
}
});
});
}
async function runLoadTest() {
console.log(`Starting load test with ${NUM_CLIENTS} concurrent clients...`);
console.log(`Each client will make ${EDITS_PER_CLIENT} edits.\n`);
// Connect all clients
const connectPromises = [];
for (let i = 0; i < NUM_CLIENTS; i++) {
connectPromises.push(createClient(i));
}
const connectedClients = await Promise.all(connectPromises);
clients.push(...connectedClients);
// Wait for sync propagation
console.log("\nWaiting 3 seconds for sync propagation...");
await new Promise((r) => setTimeout(r, 3000));
// Verify convergence: all clients should have the same content
const contents = clients.map((c) => c.ydoc.getText("default").toString());
const allEqual = contents.every((c) => c === contents[0]);
console.log(`\n--- Results ---`);
console.log(`Total clients: ${NUM_CLIENTS}`);
console.log(`Total edits: ${NUM_CLIENTS * EDITS_PER_CLIENT}`);
console.log(`Final document length: ${contents[0].length} characters`);
console.log(`All clients converged: ${allEqual ? "YES" : "NO"}`);
if (!allEqual) {
console.error("CONVERGENCE FAILURE - CRDT conflict resolution failed!");
// Show diff lengths for debugging
contents.forEach((c, i) => {
console.log(` Client ${i}: ${c.length} chars`);
});
} else {
console.log("\nCRDT convergence confirmed. All clients have identical state.");
}
// Cleanup
clients.forEach((c) => c.provider.destroy());
process.exit(allEqual ? 0 : 1);
}
runLoadTest().catch(console.error);
What does this script do?
The script spawns 10 independent Yjs clients, each connected to the same document on your Hocuspocus server via WebSocket. Each client inserts 20 random edits at random positions in the shared text. After a sync window, the script checks whether all 10 clients have identical document content.
If the CRDT implementation is correct, all clients will converge to the same state regardless of the order edits arrived. This is the core guarantee of the YATA CRDT that Yjs implements.
Authentication bypass for load testing
This script connects via y-websocket without authentication tokens. Your Hocuspocus server's onAuthenticate hook will reject unauthenticated connections. For load testing, you can temporarily comment out the authentication check in collab-server/src/index.ts or generate valid test tokens and pass them as URL parameters. Remember to restore authentication after testing.
- Run the load test by executing:
node load-test.mjs
- You should see each client connect, insert edits, and then a final report showing all clients converged to the same document state.
Seeing connection errors?
If you see authentication failures, temporarily remove the onAuthenticate hook from your Hocuspocus server for this test. Restart the collab-server after making the change.
If clients connect but the convergence check fails, increase the wait time from 3 seconds to 5 seconds. Slower machines may need more time for all WebSocket messages to propagate.
Help me troubleshoot my Yjs load test convergence failure.
- After the load test passes, restore the onAuthenticate hook in collab-server/src/index.ts if you removed it.
What does convergence prove?
When 10 clients make 200 total edits at random positions and all converge to identical content, you have empirical proof that the Yjs CRDT resolves conflicts correctly. In an interview, you can state: "I load tested with 10 concurrent editors making simultaneous random insertions, and verified 100% convergence. CRDTs guarantee this mathematically, and I confirmed it experimentally."
Secret mission
Offline Editing with IndexedDB
What happens when a user loses their connection mid-edit? In a production collaborative editor, offline support is not optional. Your challenge: add IndexedDB persistence so edits survive disconnection and automatically sync when the user reconnects, powered by the same CRDT guarantees you already built.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers: PostgreSQL 16 and Redis 7 (via docker-compose.yml).
- Local project files: the CollabNotes monorepo with client/, server/, collab-server/, and worker/ directories.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with CollabNotes.
- Your Docker containers continue running in the background. There are no costs involved.
- Your PostgreSQL database retains all users, notes, categories, and tasks you created.
- Your Redis instance retains queued jobs and cached Yjs documents.
Pause - I'll come back to this later
Stop the Docker containers to free up memory and CPU, but keep all data intact for when you return.
- Open a terminal and navigate to your CollabNotes project folder (the directory containing docker-compose.yml).
- Stop the containers by running:
docker compose stop
What does this do?
This halts the PostgreSQL and Redis containers without removing them or their data volumes. When you're ready to resume, run docker compose start from the same directory to pick up where you left off.
- Stop any running Node.js processes (the API server, collaboration server, worker, and Vite dev server) by pressing Ctrl+C in each terminal.
Delete - I don't want to use this again
Remove all project resources completely. This deletes your database data, Redis data, and all local files.
- Stop any running Node.js processes by pressing Ctrl+C in each terminal.
- Navigate to your CollabNotes project folder (the directory containing docker-compose.yml).
- Remove the Docker containers and all associated volumes by running:
docker compose down -v
What does this do?
The -v flag removes the named volumes (pgdata and redisdata) along with the containers. This permanently deletes your PostgreSQL database and Redis data.
- Navigate up one level so you're outside the project folder, then delete it by running:
macOS
cd ..
rm -rf collabnotes
Windows
cd ..
rmdir /s /q collabnotes
Nice Work!
Nice Work!
You just built a production-grade collaborative note-taking platform from scratch. CollabNotes demonstrates mastery of distributed systems, real-time communication, and background job processing. This is the kind of end-to-end system architecture that comes up in Staff Engineer interviews.
You've learned how to:
- Build a real-time collaborative editor using Yjs CRDTs with Tiptap, achieving conflict-free multi-user editing with live cursors and presence awareness.
- Design a 3-tier architecture with clear service separation: a React SPA for presentation, Express and Hocuspocus for the application layer, and PostgreSQL with Redis for data persistence.
- Implement a production task scheduling system with BullMQ featuring cron-based recurring jobs, exponential backoff retries, and dead-letter queues for failed jobs.
- Add offline-first editing with IndexedDB persistence and automatic CRDT-based sync on reconnect.
Ready to quiz yourself?