Summarize with AI:
Build a real-time collaborative notes app with Replicache where users can create, edit, delete notes and react with emojis. We will see every change we make sync instantly across all open browser tabs.
Web applications have a latency problem. Every interaction triggers a round trip. Your action leaves the browser, hits the server, gets processed and returns as a response. Only then does your screen update.
The problem is these round trips aren’t always reliable. Poor network conditions or server overload mean responses arrive late or not at all. So your users get loading spinners or skeleton screens.
Replicache flips this model by being local-first. Instead of waiting on the server for every interaction, we write to a local database first and sync later. Users see the changes immediately while the network works quietly in the background.
In this article, we will build a notes app where users can create, edit and delete notes; react with emojis; and see every change sync across all open browser tabs in real time.
To follow along, this guide assumes you have:
Replicache is a local-first sync engine. It uses IndexedDB, a database built into the browser, to store your app’s data locally. Every user gets their own copy on their device, so when they make a change, it saves locally first. Then later, it replays those changes to the server.
Replicache isn’t designed for every use case. For sensitive systems like banking, where data integrity is delicate, you wouldn’t use it. But for collaborative apps like notes, docs and project boards, Replicache works nicely. Let’s now look at some of the core concepts behind Replicache.
Before we build our app, let’s go over the core concepts that make Replicache work.
The local store is a persistent database built into the browser on top of IndexedDB. It is not memory that disappears after a refresh.
It works as a key-value store where each piece of data has a key (String) and a JSON value.
The local store is flat, meaning all the data lives together. When we code, we’ll use prefixes on our keys to keep things organized and scannable. This local store also allows you to work offline; this way, changes are saved locally and synced to the server when the connection comes back.
In a Replicache app, all data changes go through a mutator. You cannot write directly to the server or local store. Mutators are the only way to change data.
A mutator is just a function that takes a WriteTransaction and some arguments. WriteTransaction is your interface to the local store. For writing, you only need two operations: tx.set() to store a value under a key, and tx.del() to remove one.
When you call a mutator, two things happen. First, it writes to the local store immediately, so the UI updates and the user sees the change right away. Then, Replicache queues the mutation and sends it to the server in the background. No loading states, sync just happens silently.
Push is how Replicache sends local changes to the server. After a mutator runs, Replicache adds it to a pending queue, storing the mutation name and its arguments. When ready, it sends the entire queue to the push endpoint in one request.
The server processes each mutation one by one, checks the name and runs the corresponding database operation. Basically, the server is replaying what already happened locally, but against the persistent database. After processing all mutations, the server calls poke.
Poke is simple but important. Without it, data gets saved to the database, but other users have no way of knowing about the change. They’d have to wait for the next scheduled pull, which defaults to 60 seconds. That’s not great for a collaborative app.
Poke uses server-sent events (SSEs). Every browser with the app open maintains a persistent connection to the server’s poke endpoint. When the server finishes processing a push, it sends a signal to all connected clients. It doesn’t send the data itself, just a signal. Clients receive it and immediately call pull to get the latest state. This is simple and efficient.
Pull is how Replicache gets the latest state from the server. It happens in three situations: when the app first loads, when the client receives a poke and periodically in the background as a safety net (every 60 seconds by default).
The server responds with a patch, which is just an array of instructions telling Replicache what to store or remove in the local store.
Before applying the patch, Replicache checks for pending mutations in the queue. If there are any, it applies the server data first and then replays those pending mutations on top. This helps prevent local changes from being lost or overwritten by incoming server data.
We’re building a real-time collaborative notes app where users can create, edit, delete notes and react with emojis. Every change we make syncs instantly across all open browser tabs. The GIF below shows what we’ll build.

We’ll split this project into two folders: server and client. We’re doing this because they require different dependencies.
Open your terminal, navigate to where you want the project to live, and run the following commands:
mkdir notes-app
cd notes-app
mkdir server client
Now, let’s initialize and set up the server. Run the following command in your terminal:
cd server
npm init -y
Now let’s go ahead and install the dependencies we’ll need:
npm install express better-sqlite3 cors uuid
npm install -D typescript ts-node @types/express @types/better-sqlite3 @types/cors @types/uuid @types/node
Initialize TypeScript:
npx tsc --init
Then replace the generated tsconfig.json with this:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
Now that the server is set up, let’s scaffold the client. Navigate to the client directory:
cd ../client
Run the following command to create a React app with Vite:
npm create vite@latest . -- --template react-ts
npm install
Install Replicache and its React helper:
npm install replicache replicache-react
The server is responsible for storing data, processing mutations and notifying connected clients when changes occur. We’ll build this across five files, each with a single responsibility.
Create a src folder inside the server directory, that is where all of our files will live:
mkdir src
Create a file called db.ts inside the src folder and add the following to it:
import Database from "better-sqlite3";
const db = new Database("notes.db");
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS replicache_clients (
client_id TEXT PRIMARY KEY,
last_mutation_id INTEGER NOT NULL DEFAULT 0
);
`);
export default db;
In the code above, we created three tables. The notes table stores the note content, a unique ID and a timestamp. The reactions table stores each emoji reaction with a note_id that references the note it belongs to. This is how a reaction always knows which note it belongs to. The replicache_clients table tracks the last mutation each client has processed on the server. We’ll cover this in detail in the advanced concepts section.
Create a file called types.ts in the src folder and add the following to it:
export type Note = {
id: string;
content: string;
created_at: number;
};
export type Reaction = {
id: string;
note_id: string;
emoji: string;
created_at: number;
};
In this file, we define the shape of data, such as the notes and reactions. With this one file, TypeScript can catch errors throughout the app. When the data structure changes, we only need to update this one file.
Create a file called poke.ts in the src directory, and add the following to it:
import { Response } from "express";
const clients = new Set<Response>();
export function addClient(res: Response) {
clients.add(res);
}
export function removeClient(res: Response) {
clients.delete(res);
}
export function poke() {
for (const client of clients) {
client.write("data: poke\n\n");
}
}
We maintain a Set of all open browser connections. A Set is ideal here because every connection is unique, and removing one is as simple as calling delete(). With an array, you’d have to find it first.
addClient adds a browser to the Set when it connects, removeClient removes it when it disconnects, and poke loops over all connections and sends a signal down each one.
One important thing to note is that poke sends no actual data, just a signal. The browser receives it and immediately calls pull to fetch the latest data. This keeps the system clean and efficient.
Create a file called pull.ts in the src folder. This is the endpoint Replicache calls when it needs the latest data from the server. Add the following to it:
import { Request, Response } from "express";
import db from "./db";
import type { Note, Reaction } from "./types";
export function pull(req: Request, res: Response) {
const notes = db
.prepare("SELECT * FROM notes ORDER BY created_at ASC")
.all() as Note[];
const reactions = db
.prepare("SELECT * FROM reactions ORDER BY created_at ASC")
.all() as Reaction[];
const patch = [
{ op: "clear" },
...notes.map((note: Note) => ({
op: "put",
key: `note/${note.id}`,
value: note,
})),
...reactions.map((reaction: Reaction) => ({
op: "put",
key: `reaction/${reaction.id}`,
value: reaction,
})),
];
const clients = db
.prepare("SELECT client_id, last_mutation_id FROM replicache_clients")
.all() as { client_id: string; last_mutation_id: number }[];
const lastMutationIDChanges = Object.fromEntries(
clients.map((c) => [c.client_id, c.last_mutation_id]),
);
res.json({
lastMutationIDChanges,
cookie: null,
patch,
});
}
In the code above, we fetch all notes and reactions from the database and build a patch, which is an array of instructions telling Replicache what to store in the local store. Each instruction has an op set to "put", a key and a value.
Notes use the prefix note/ and reactions use reaction/, so we can differentiate them in the flat local store. Without these prefixes, everything would live together with no way to differentiate a note key from a reaction key.
We also spread both arrays into one flat list because Replicache expects one flat array of instructions, not nested arrays. The response also includes cookie and lastMutationIDChanges, which we’ll look into in the advanced concepts section.
Create a file called push.ts and add the following to it:
import { Request, Response } from "express";
import db from "./db";
import { poke } from "./poke";
export function push(req: Request, res: Response) {
const mutations = req.body.mutations;
for (const mutation of mutations) {
const { clientID, id, name, args } = mutation;
const row = db
.prepare(
"SELECT last_mutation_id FROM replicache_clients WHERE client_id = ?",
)
.get(clientID) as { last_mutation_id: number } | undefined;
const lastMutationID = row?.last_mutation_id ?? 0;
if (id <= lastMutationID) {
continue;
}
if (name === "createNote") {
db.prepare(
"INSERT OR IGNORE INTO notes (id, content, created_at) VALUES (?, ?, ?)",
).run(args.id, args.content, args.created_at);
}
if (name === "addReaction") {
db.prepare(
"INSERT OR IGNORE INTO reactions (id, note_id, emoji, created_at) VALUES (?, ?, ?, ?)",
).run(args.id, args.note_id, args.emoji, args.created_at);
}
if (name === "updateNote") {
db.prepare("UPDATE notes SET content = ? WHERE id = ?").run(
args.content,
args.id,
);
}
if (name === "deleteNote") {
db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
db.prepare("DELETE FROM reactions WHERE note_id = ?").run(args.id);
}
db.prepare(
"INSERT INTO replicache_clients (client_id, last_mutation_id) VALUES (?, ?) ON CONFLICT(client_id) DO UPDATE SET last_mutation_id = ?",
).run(clientID, id, id);
}
poke();
res.json({ status: "ok" });
}
The request body contains an array of mutations. Each mutation has a name and an args object. The name tells us what operation to run, and args contains the specific data that operation needs.
The function loops through each mutation and checks its name. For createNote, we insert a new note into the notes table. For addReaction, we insert a reaction into the reactions table. For updateNote, we update the content of an existing note where the ID matches. For deleteNote, we run two deletes: one for the note and one for all its reactions. We do this because we don’t want reactions pointing to a note that no longer exists.
Create a file called index.ts in your src folder to bring everything together:
import express from "express";
import cors from "cors";
import { pull } from "./pull";
import { push } from "./push";
import { addClient, removeClient } from "./poke";
const app = express();
app.use(cors());
app.use(express.json());
app.post("/api/push", push);
app.post("/api/pull", pull);
app.get("/api/poke", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
addClient(res);
req.on("close", () => {
removeClient(res);
});
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
In the code above, we register the push and pull routes and set up the poke endpoint. The push and pull routes are straightforward in the sense that they receive a request, do their job and respond.
The poke endpoint works differently. Instead of responding immediately and closing the connection, it sets SSE headers and keeps the connection open as long as the browser is connected. That open connection is the channel the server uses to send poke signals to the client.
Notice the keep-alive header. This tells the server not to close the connection after responding. As long as the browser has the app open, the connection stays alive. When a browser disconnects or closes the tab, req.on("close") fires automatically and removes that connection from the clients Set. This prevents trying to send a poke to a connection that no longer exists.
Now we’ll build the React frontend. This is where we create the UI for users to create, edit, delete and react to notes with emojis across different tabs.
We’ll set up:
Run the following command to navigate to the client folder:
cd ../client
Create three new files in the src directory:
touch src/mutators.ts src/replicache.ts src/types.ts
Both the client and server need to agree on the same data structure. Add this to your types.ts file:
export type Note = {
id: string;
content: string;
created_at: number;
};
export type Reaction = {
id: string;
note_id: string;
emoji: string;
created_at: number;
};
These are the same types as the server. By sharing the same type definitions, TypeScript enables consistency between client and server.
As we covered in the core concepts section, mutators are the only way to change data in a Replicache app. Update your mutators.ts file with the following:
import type { WriteTransaction } from "replicache";
import type { Note, Reaction } from "./types";
export const mutators = {
createNote: async (tx: WriteTransaction, note: Note) => {
await tx.set(`note/${note.id}`, note);
},
addReaction: async (tx: WriteTransaction, reaction: Reaction) => {
await tx.set(`reaction/${reaction.id}`, reaction);
},
deleteNote: async (tx: WriteTransaction, args: { id: string }) => {
await tx.del(`note/${args.id}`);
},
updateNote: async (
tx: WriteTransaction,
args: { id: string; content: string },
) => {
const existing = await tx.get(`note/${args.id}`);
if (existing) {
await tx.set(`note/${args.id}`, {
...(existing as Note),
content: args.content,
});
}
},
};
Every mutator receives a WriteTransaction as its first argument, which is your connection to the local store. The second argument is the data needed for that operation.
createNote stores a note in the local store under note/{id}. addReaction does the same for reactions under reaction/{id}. deleteNote removes a note using tx.del().
On the other hand, updateNote works differently. It first reads the existing note with tx.get(), spreads it and overwrites just the content field. Everything else stays the same.
One important thing to note is that these mutators don’t talk to the server directly. They only write to the local store. Replicache handles sending them to the server separately by queuing each mutation and sending the entire queue to the push endpoint.
Open your replicache.ts file and add the following to it:
import { Replicache } from "replicache";
import { mutators } from "./mutators";
export const rep = new Replicache({
name: "notes-app",
mutators,
pushURL: "http://localhost:3000/api/push",
pullURL: "http://localhost:3000/api/pull",
});
const eventSource = new EventSource("http://localhost:3000/api/poke");
eventSource.onmessage = () => {
rep.pull();
};
We create a Replicache instance and export it as rep so it can be used throughout our components. The name is a unique identifier for the local store. mutators tells Replicache what write operations are available. pushURL and pullURL point to our server endpoints.
At the bottom, we open a permanent connection to the poke endpoint using EventSource. When the server sends a poke signal, the onmessage handler fires and calls rep.pull() to fetch the latest data.
This is where everything becomes visible in the browser. Open your App.tsx, clear the file and add these imports at the top:
import { useSubscribe } from "replicache-react";
import { rep } from "./replicache";
import type { Note, Reaction } from "./types";
Next, we need to subscribe to data from the local store.
Add these inside your App function:
const notes = useSubscribe(
rep,
async (tx) => {
const list = await tx.scan({ prefix: "note/" }).values().toArray();
return list as Note[];
},
{ default: [] },
);
const reactions = useSubscribe(
rep,
async (tx) => {
const list = await tx.scan({ prefix: "reaction/" }).values().toArray();
return list as Reaction[];
},
{ default: [] },
);
In the code above, the useSubscribe hook watches the local store and rerenders your component when data changes. tx.scan() filters by prefix. Remember when we said we needed to prefix our keys for easy scanning? That’s what we’re doing here. Reactions use their own prefix as well. The { default: [] } option prevents your component from breaking before the data loads.
Next, let’s create functions that run when the user performs an action or triggers an action. It could be creating, updating, reacting to or deleting notes. Each handler calls a mutator corresponding to the action:
const handleUpdateNote = async (id: string, content: string) => {
if (content.trim()) {
await rep.mutate.updateNote({ id, content: content.trim() });
}
};
const handleAddReaction = async (noteId: string, emoji: string) => {
const id = crypto.randomUUID();
await rep.mutate.addReaction({
id,
note_id: noteId,
emoji,
created_at: Date.now(),
});
};
const handleDeleteNote = async (id: string) => {
await rep.mutate.deleteNote({ id });
};
const emojis = ["👍", "❤️", "😂", "🔥"];
handleUpdateNote calls the updateNote mutator and trims whitespace to prevent empty notes. handleAddReaction generates a unique ID, captures the current timestamp and calls the addReaction mutator. handleDeleteNote removes a note from the store. The emojis array contains the reactions users can choose from.
Now this is where we see everything visually. We render the notes grid, the add note button, the editable content, the emoji reactions and the delete button. Since we already have the handler functions, the markup is straightforward:
{notes.map((note) => (
<div key={note.id}>
<p
contentEditable
suppressContentEditableWarning
onBlur={(e) => handleUpdateNote(note.id, e.currentTarget.textContent ?? "")}
>
{note.content}
</p>
{emojis.map((emoji) => {
const count = reactions.filter(
(r) => r.note_id === note.id && r.emoji === emoji
).length;
return (
<button key={emoji} onClick={() => handleAddReaction(note.id, emoji)}>
{emoji} {count > 0 && <span>{count}</span>}
</button>
);
})}
<button onClick={() => handleDeleteNote(note.id)}>🗑</button>
</div>
))}
That’s everything wired up. If you’ve followed along, your app should be rendering correctly in your browser.
First, start the server:
cd server
npm run start
Then, open another terminal for the client:
cd client
npm run dev

Replicache has many advanced features, but for this guide, we’ll focus on cookie and lastMutationIDChanges. We referenced both earlier in our code, so let’s get a quick overview of what they do.
When Replicache sends a push request containing all pending mutations, it attaches an ID to each one. The server uses these IDs to track which mutations it has already processed.
When the server receives the request, it checks each ID. If it has already processed a mutation ID, it skips it. If the ID is new, it processes the mutation. The server then stores the highest ID it has seen for that client. This prevents duplicate processing. For example, if the network fails and Replicache resends the same request, the server skips the mutations it already processed and only handles the new ones.
Now let’s update the pull.ts file to see this in action. Replace the lastMutationIDChanges variable inside your pull function:
const clients = db
.prepare("SELECT client_id, last_mutation_id FROM replicache_clients")
.all() as { client_id: string; last_mutation_id: number }[];
const lastMutationIDChanges = Object.fromEntries(
clients.map((c) => [c.client_id, c.last_mutation_id])
);
We set cookie to null in our pull response for this article, which means the server sends everything every time. But there’s a problem: when you open the app in a new browser, Replicache sees the same null cookie and thinks nothing has changed. It ignores the patch array, so notes won’t appear.
Here’s how we can fix this. Go back to your pull.ts file and change cookie: null to cookie: Date.now(). Now every pull response has a fresh timestamp. Replicache sees a new cookie and applies the patch data.
The important thing here is that the timestamp is always incremental. Date.now() always increases. It never decreases. So Replicache always sees a higher number than before, indicating fresh data. That’s why it applies the patch every single time.
Update your final response with the following:
res.json({
lastMutationIDChanges,
cookie: Date.now(),
patch,
});
The cookie means Replicache applies the patch every time. The mutation IDs keep it from processing the same mutation twice. And together, they enable proper sync.

In this post, we explored how Replicache works. It pulls data when the app starts, poke notifies connected clients of changes with a signal, and mutations are queued and later replayed to the server.
In all of this, Replicache isn’t right for every use case. For apps dealing with sensitive data like medical records or payments, you need server verification first, so Replicache isn’t a good fit. But for collaborative apps where user experience is the priority, RReplicache is good, as it enhances the user’s experience. Check out the documentation to explore more.
Chris Nwamba is a Senior Developer Advocate at AWS focusing on AWS Amplify. He is also a teacher with years of experience building products and communities.