skip to content
Tushar's Avatar Tushar Tripathi

Replicache Notes (Library to build local-first)

/ 11 min read

Replicache is a client-side sync framework for building realtime, collaborative, local-first web apps. I recently went through their docs and found it quite interesting. This post is a summary of the concepts and an overview of the library. Some general points -

  • It’s a client side local reactive database with support for offline-first and sync.
  • There is some expectations from what the server should do, but it’s not opinionated about the server implementation.
  • There are a few other client side reactive DBs like Watermelon and TinyBase with support for sync, but Replicache feels more friendly.
    • It doesn’t require you to declare all the models and schema upfront, and behaves more like a key-value store.
    • The sync support is more robust and first-class.
    • There is a similar concept of push and pull endpoints for sync like WatermelonDB.
    • There is no query engine as such in Replicache. No SQL, Datalog etc. You just use the usual map, filter, etc. javascript functions. Some discussion on this here.
  • I earlier used to think it’s CRDT based, but the mental model is actually much simpler. CRDTs are excellent for true local-first applications but are an overhead for where a central authoritative server can be assumed. An explicit approach of conflict resolution when intent is passed alongside changes is much easier to reason about. Figma came to the same conclusion, notion doesn’t use CRDTs either. Another writeup on need of CRDTs here.
  • CRDTs however can be synced on top of Replicache.
  • The difference in mental model between CRDT based approaches and Replicache -
    • CRDT => I have the same state schema defined on client and server. If I change any field, it will sync automatically on the server counterpart. If multiple users are changing different parts of the state, any conflicts will be automatically resolved(but not necessarily in the way you would prefer), and replicated to all clients.
    • Replicache => I have a state schema defined on client which server knows about. And it’s not the state changes which get synced, but the intent to change the state. So if a todo is marked done, the server doesn’t just get say {id: "todo1", done: true}(the state changes delta). But more like {id: "todo1", intent: "markComplete"}(also called mutation). The server can now decide what to do with this and send back any state changes which might have resulted from this mutation. The onus of conflict resolution falls on the server, but as we’ll see this actually makes things simpler.

Concepts

  • Cache/Store => Uses IndexedDB, data is persistent across tabs and reloads. While the exact number depends on browsers, IndexedDB can store data in GBs.
  • mutations => these are functions which are passed an instance of a Replicache Transaction object and app defined custom arguments. The mutations are queued on client and sent to server. Every mutation has both client and server counterpart. Server’s result is authoritative and might override or add to client’s result. User will see the UI changes immediately for whatever they do locally as client side mutation runs immediately. If there are any conflicts, or rejection of mutations, the UI will adjust accordingly.
  • subscription => used to subscribe to the state. It’s very similar to selectors in the redux/zustand world.
  • sync => for syncing the latest data from server. This is done via a pull endpoint exposed by the server.

Code samples for the concepts

The code is reduced to bare minimum for brevity, the purpose is to show the concepts.

Creating Replicache

Pretty straightforward, just pass the name of the cache. As the cache is persisted across tabs on the same device, the name should usually be unique per logged in user.

import { Replicache } from "replicache";

const store = new Replicache({
	name: "todoForUser1",
});

Mutators

Mutators are defined while creating the cache. They are functions which mutate the data. They’re passed a transaction object which lets the function operate on the state atomically. That is the state changes from the mutation are visbile to the UI only after the function has run.

const store = new Replicache({
	name: "todoForUser1",
	mutators: {
		// tx = transaction
		addTodo: async (tx, { id, text }) => {
			const todo = { id, text, complete: false };
			await tx.set(`todo/${id}`, todo);
			return todo;
		},
		markComplete: async (tx, { id, complete }) => {
			const key = `todo/${id}`;
			const todo = await tx.get(key);
			if (!todo) return;
			todo.complete = complete;
			await tx.set(key, todo);
		},
		removeTodo: async (tx, { id }) => {
			await tx.del(`todo/${id}`);
		},
	},
});

The passed in transaction has these methods:

  • isEmpty() => returns true if the state is empty
  • has(key) => returns true if the key exists
  • get(key) => returns the value for the key
  • set(key, value) => sets the value for the key
  • del(key) => deletes the key
  • scan(filters) => returns an iterator, useful for getting or mutating multiple keys based on some criteria

You can call these mutators from anywhere in your app, to mutate the store. The mutator functions are all put on the store.mutate path but they don’t require the first tx argument, just the custom arguments.

const todo = await store.mutate.addTodo({
	// ids must be unique and client generated
	id: nanoid(),
	text: "get groceries",
});

A mutation can be defined with the mutation name and the arguments(must be JSON serializable) it accepts. For every possible mutation, server also has corresponding mutation functions defined as we’ll see later.

Mutation functions must be idempotent

It’s important for the mutation functions to be idempotent as they can be rerun on client side and can be retried multiple times while sending to server. In practice this usually means that the identifier has to be generated before the mutation function is called and not inside it.

Subscriptions

This is very similar to zustand/redux selectors. You can use all the methods available on transaction listed above, other than set and del.

const todoId = "123";
const todoSelector = async (tx) => {
	(await tx.get(`todo/${todoId}`)) ?? null;
};
const unsub = store.subscribe(todoSelector, (todo) => {
	console.log("my subscribed todo", todo);
});

// you can also get one off value without subscribing
const todo = await store.get(todoSelector);

// or for subscribing to all todos
store.subscribe(
	async (tx) => {
		const todos = [];
		for await (const { key, value } of tx.scan({ prefix: "todo/" })) {
			todos.push({ id: key.split("/")[1], ...value });
		}
		return todos;
	},
	(todos) => {
		// this is called whenever the todos change
		console.log("todos", todos);
	},
);

The callbacks are called only if the value returned by the selector changes. A cool thing which they do with the selector function is an in-built Reselect like mechanism. The selector function is called only if the keys it accessed last time changes. Because of this internal tracking of keys, in the above code, even though we’re creating a todos array from the scan, the selector won’t run if the todos don’t change thus preventing unnecessary subscription callbacks. If something like this were to be done in the Redux/Zustand world, we would either have to break down the selector(for using reselect) or use custom equality check functions.

Sync

The most important part, for why we would use Replicache. In simple terms, the sync is done via two server endpoints, push and pull.

  • The push endpoint is called to send mutations to the server.
  • The pull endpoint is called to get the latest data from the server.

Push - Sending mutations to server

This is about pushing the jsonified mutations to server. A mutation is name + arguments. The mutations are usually batched. Example payload sent by Replicache to the server -

{
	"mutations": [
		{
			// unique id per replicache instance
			"clientID": "client1-instance1",

			// same for instances across tabs if they're
			// deeply equal. hash over client attributes
			"clientGroupId": "client1",

			// mutation id, sequential integer
			// created by replicache to track mutations
			"id": 1,

			"name": "addTodo",
			"args": {
				"id": "123",
				"text": "get groceries"
			}
		},
		{
			"clientID": "client2-instance1",
			"clientGroupId": "client2",
			"id": 2,
			"name": "markComplete",
			"args": {
				"id": "456",
				"complete": true
			}
		}
	]
}

The server can define a endpoint to take the above mutations, handle them however it wants to land on an authoritative state which will be synced to everyone. In case of failure, the mutations would be stored locally and retried later. There is no response required for the push call. There is an expectation from the server to store the last mutation id per client, so it can be sent back in the pull response. This is used by the client to track the mutations which have been applied on the server.

Pull - Syncing from server

Replicache calls the pull endpoint periodically as well as at startup. Further, you can manually invoke the pull function any time you want. For e.g. if somehting on the server changes, they suggest to notify the client via mechanisms like websocket or server sent events. This is called poke. On receiving a poke, the app should call store.pull() to refresh the data.

If there are local mutations not yet acknowledged by the server, they are rebased on top of the latest data received in pull(that is rewinded and replayed). This is similar to git rebase. This can be done because the state is versioned. As the state is also transactional, this whole rewinding and replaying is done in a single atomic transaction.

Websockets not required for real time sync

An interesting point to note here is that there is no enforcement of using websockets for syncing(the pull part). This is quite useful, scaling websocket servers is hard as they’re stateful.
A consequence of this is that the latency of syncing between clients would be higher. If user1 changes a field, server would send user2 a poke to sync instead of the change itself. The overall simplicity feels worth the slight tradeoff though.

Payload for pull request -

{
	// kinda hash over replicache client attributes
	"clientGroupID": "client1",

	// The cookie that was received last time a pull was done
	// not related to browser cookies
	"cookie": 123
}

A sample JSON for the pull response -

{
	"lastMutationIDChanges": {
		"client1": 2
	},
	"cookie": 123,
	"patch": [
		{
			"op": "put",
			"key": "todo/123",
			"value": {
				"id": "123",
				"text": "get groceries",
				"complete": false
			}
		},
		{
			"op": "put",
			"key": "todo/456",
			"value": {
				"id": "456",
				"text": "do laundry",
				"complete": true
			}
		},
		{
			"op": "del",
			"key": "todo/789"
		}
	]
}

There are three parts-

  • patch => the changes to be applied to the local store. Note that this is not the list of mutations, but the actual data. It dictates the changes in state of the store. This is the authoritative data from the server which takes into account mutations coming from all connected clients. Fields in patch -
    • op => put/del/clear, clear is used to clear the entire store
    • key => the key to be mutated, must be passed if op is put/del
    • value => the value to be set for the key, passed only if op is put
  • lastMutationIDChanges => the mutations which have been applied on the server. This is used to rebase the local mutations yet to be applied on top of the latest data received in pull.
  • cookie => This is for the server to send minimal data, only sending patches for whatever has changed since the last pull. It’s an orderable string or number, set by server. It’s passed as null in the first pull request. The server needs to maintain a version on its side which it can use for this cookie field. Every mutation will lead to increase in the version, which gets stamped alongside the updated object. The server can send objects updated since last version in pull request. Replicache has a page detailing different strategies for implementing this.

Conflict Resolution

The server being the authoritative source of truth, makes this quite simple to think of. For e.g. if two clients try to reserve the same seat, the server can just reject the second request. If two clients update the same field in a task, the last write can win. If two tasks are created at the same time, there is no conflict to resolve.

The conflicts can be more nuanced, and very different for different type of mutations. But with this explicit approach, the server knows about the user intent and is in a much better position to resolve them.

Final Notes

I am a big fan of flux architecture for complex webapps. I’ve written about it here, in the context of 100ms’ Web SDK. Replicache feels like a natural extension of flux, distributed across multiple clients with an ability to tolerate disconnections efficiently. The involved concepts around reactive data stores, modelling changes in terms of mutations, immutability, versioning and applying patches coming from server are not new in isolation. But seeing it all together with a versioned B+ Tree based reactive data store is a thing of beauty. The sync is very well thought out and reduces the burden on server significantly compared to other approaches.

There are a few things like schema migrations and different sync strategies I didn’t cover in above. Do check Replicache docs for more details.