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.
Prerequisites
To follow along, this guide assumes you have:
- Basic JavaScript/TypeScript knowledge
- Understanding of HTTP (GET/POST)
- Node.js installed
- Basic SQL knowledge
- Familiarity with React
What Is 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.
Core Concepts
Before we build our app, let’s go over the core concepts that make Replicache work.
Local Store
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.
Mutators
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
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
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
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.
Project Setup
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:
Setting up the Server
Now, let’s initialize and set up the server. Run the following command in your terminal:
Now let’s go ahead and install the dependencies we’ll need:
Initialize TypeScript:
Then replace the generated tsconfig.json with this:
Setting Up the Client
Now that the server is set up, let’s scaffold the client. Navigate to the client directory:
Run the following command to create a React app with Vite:
Install Replicache and its React helper:
Building the Server
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:
Setting Up the Database
Create a file called db.ts inside the src folder and add the following to it:
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.
Defining the Types
Create a file called types.ts in the src folder and add the following to it:
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.
Building the Notification System
Create a file called poke.ts in the src directory, and add the following to it:
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.
Fetching Data from the Server
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:
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.
Processing Mutations
Create a file called push.ts and add the following to it:
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.
Putting the Server Together
Create a file called index.ts in your src folder to bring everything together:
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.
Building the Client
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:
- Types to align the client and server
- Mutators to handle every write operation
- A Replicache instance to connect to the server
- The UI
Run the following command to navigate to the client folder:
Create three new files in the src directory:
Defining the Types
Both the client and server need to agree on the same data structure. Add this to your types.ts file:
These are the same types as the server. By sharing the same type definitions, TypeScript enables consistency between client and server.
Defining the Mutators
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:
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.
Setting Up the Replicache Instance
Open your replicache.ts file and add the following to it:
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.
Building the UI
This is where everything becomes visible in the browser. Open your App.tsx, clear the file and add these imports at the top:
Next, we need to subscribe to data from the local store.
The Subscriptions
Add these inside your App function:
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.
Event Handlers
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:
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.
UI Components
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:
That’s everything wired up. If you’ve followed along, your app should be rendering correctly in your browser.
First, start the server:
Then, open another terminal for the client:
Advanced Concepts
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.
lastMutationIDChanges
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:
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:
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.
Conclusion
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.



![How SafeStyle Shipped 100,000+ Orders in Under a Year with ShipBob [Case Study]](https://www.shipbob.com/wp-content/uploads/2026/10/ba05c72624a0a82d7de08863e1e0a1a2.jpg)






