Replicache + Reflect: a TypeScript local-first stack
A practical guide to implementing a local-first architecture with Replicache for synchronization and Reflect for reactive state management. Discover how to build offline-first applications with automatic data binding and conflict resolution.
What is this stack and why you should care
Replicache is a TypeScript library that synchronizes the client's local database with the server, maintaining real-time state and automatically resolving conflicts. Reflect is a reactive data binding framework that transforms JavaScript objects into automatically updated observables. Together, they form a complete stack for building local-first applications where the client is the primary source of truth and the server a trusted replica.
The combination is particularly powerful because Replicache handles probabilistic synchronization and conflict resolution, while Reflect provides reactive data binding without boilerplate. The result: applications that work flawlessly offline, automatically synchronize when the network is available, and deliver immediate and fluid user experience.
General system architecture
A Replicache + Reflect application is organized in three layers:
The three layers of the architecture
- Client: Replicache synchronizes the local IndexedDB database with the server; Reflect makes model objects reactive
- Mutation Engine: Client mutations are applied locally in an optimistic manner, then replicated to the server
- Server: Validates mutations, resolves conflicts, and pushes changes to all clients
The flow is: user modifies a value in UI → Reflect notifies the binding → Replicache applies the mutation locally (optimistic update) → Replicache synchronizes with the server via pull/push → server validates and persists → server pushes ack and other clients receive the update.
Initial project setup
Start by installing the necessary dependencies:
npm install replicache reflect-metadata rxjs
npm install -D typescript @types/node ts-node
Configure TypeScript with support for decorators:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}
Define data model with Reflect
Use Reflect to create reactive classes:
import 'reflect-metadata';
import { Observable } from 'rxjs';
export class Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
constructor(data: any) {
this.id = data.id;
this.title = data.title;
this.completed = data.completed;
this.createdAt = new Date(data.createdAt);
}
markComplete() {
this.completed = true;
}
updateTitle(newTitle: string) {
this.title = newTitle;
}
}
export class TodoStore {
private todos = new Map<string, Todo>();
addTodo(todo: Todo): void {
this.todos.set(todo.id, todo);
}
getTodo(id: string): Todo | undefined {
return this.todos.get(id);
}
getAllTodos(): Todo[] {
return Array.from(this.todos.values());
}
}
Initialize Replicache in the client
Create a Replicache instance and define pull and push handlers:
import { Replicache } from 'replicache';
// Initialize Replicache
const rep = new Replicache({
name: 'app-todo-list',
licenseKey: process.env.REPLICACHE_LICENSE || 'noop',
pullURL: 'http://localhost:3000/api/pull',
pushURL: 'http://localhost:3000/api/push',
});
// Define mutations
export const mutators = {
async createTodo(
tx: any,
{ id, title }: { id: string; title: string }
) {
await tx.set(`todo/${id}`, {
id,
title,
completed: false,
createdAt: new Date().toISOString(),
});
},
async updateTodo(
tx: any,
{ id, completed, title }: any
) {
const todo = await tx.get(`todo/${id}`);
if (todo) {
await tx.set(`todo/${id}`, {
...todo,
...(completed !== undefined && { completed }),
...(title !== undefined && { title }),
});
}
},
async deleteTodo(tx: any, { id }: { id: string }) {
await tx.del(`todo/${id}`);
},
};
rep.mutators = mutators;
Implement the pull handler on client
The pull handler downloads data from the server and updates the local store:
// In client setup
rep.onPull.subscribe(async (pullRequest) => {
const response = await fetch('http://localhost:3000/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientID: rep.clientID,
cookie: pullRequest.cookie,
}),
});
const data = await response.json();
// Replicache automatically applies changes to the local database
// synchronizing with the store
});
Implement the push handler on server
In your Express/Node backend, handle pushes from the client:
import express from 'express';
const app = express();
app.use(express.json());
// Volatile storage for this example (use DB in production)
const todos = new Map<string, any>();
let serverVersion = 0;
app.post('/api/push', async (req, res) => {
const { clientID, mutations, lastMutationID } = req.body;
try {
for (const mutation of mutations) {
const { type, args } = mutation;
switch (type) {
case 'createTodo':
todos.set(args.id, {
id: args.id,
title: args.title,
completed: false,
createdAt: new Date(),
});
serverVersion++;
break;
case 'updateTodo':
if (todos.has(args.id)) {
const todo = todos.get(args.id);
if (args.completed !== undefined)
todo.completed = args.completed;
if (args.title !== undefined) todo.title = args.title;
serverVersion++;
}
break;
case 'deleteTodo':
todos.delete(args.id);
serverVersion++;
break;
}
}
res.json({ lastMutationIDReceived: lastMutationID });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Pull endpoint for synchronization
The server must provide an endpoint that Replicache queries to sync state:
app.post('/api/pull', (req, res) => {
const { cookie } = req.body;
const lastSeenVersion = cookie?.version ?? 0;
// Calculate changes to send to client
const changes: any[] = [];
// In a real app, you'd save changes with timestamps
// For simplicity, we return all todos
const entries = Array.from(todos.entries()).map(([id, data]) => ({
key: `todo/${id}`,
value: data,
}));
res.json({
cookie: { version: serverVersion },
changes: entries,
});
});
Reactive binding with Reflect
Use Reflect to automate data binding between model and UI:
// Vue 3 / React component as example
import { ref, watch } from 'vue';
export default {
setup() {
const todos = ref<Todo[]>([]);
// Observe changes in Replicache
rep.subscribe(
(tx) => tx.scan({ prefix: 'todo/' }),
{
onData: async (data) => {
const todoList = [];
for await (const entry of data) {
todoList.push(new Todo(entry.value));
}
todos.value = todoList;
},
}
);
const createTodo = async (title: string) => {
const id = crypto.randomUUID();
await rep.mutate.createTodo({ id, title });
};
const toggleTodo = async (id: string) => {
const todo = todos.value.find((t) => t.id === id);
if (todo) {
await rep.mutate.updateTodo({
id,
completed: !todo.completed,
});
}
};
return { todos, createTodo, toggleTodo };
},
};
Conflict resolution and strategies
Replicache handles conflicts using Last-Write-Wins (LWW) by default. You can customize the strategy:
// Custom LWW strategy
rep.opts.conflictStrategy = 'lastWriteWins';
// Or define a custom strategy
export const conflictResolver = (
local: any,
remote: any,
base: any
): any => {
// Example: if title was modified on both sides,
// keep the longer version (better UX)
if (local.title !== base.title && remote.title !== base.title) {
return {
...remote,
title:
local.title.length > remote.title.length
? local.title
: remote.title,
};
}
// Fallback: remote version
return remote;
};
Optimize performance and caching
Use limited scans and indexing to improve queries:
// Efficient queries with prefix scanning
const getCompletedTodos = async () => {
const results = [];
const scan = await rep.query((tx) =>
tx.scan({ prefix: 'todo/', limit: 100 })
);
for await (const { value } of scan) {
if (value.completed) {
results.push(value);
}
}
return results;
};
// Caching with TTL
const cacheKey = `completed-todos-${Date.now()}`;
const getCachedCompletedTodos = async () => {
const cached = await rep.query((tx) => tx.get(cacheKey));
if (cached) return cached;
const todos = await getCompletedTodos();
await rep.mutate.cache({ key: cacheKey, data: todos });
return todos;
};
Ensure offline-first experience
With Replicache + Reflect, the app remains fully functional offline:
Offline-first checklist
| Scenario | Behavior |
|---|---|
| User offline creating todo | Applied locally, queued for sync |
| Network becomes available | Mutations auto-sent to server |
| Conflict resolved by server | Client receives ack and syncs final state |
| Crash during offline | IndexedDB persists, recovered on restart |
Best practices for production
Here are the key recommendations for a robust implementation:
Recommended guidelines
- Versioning: use timestamps or incremental versions on server for precise tracking
- Authentication: always validate mutations server-side with JWT or session token
- Persistence: use PostgreSQL or Firestore with ACID transactions for consistency
- Throttling: limit push/pull frequency (e.g., 1 push every 500ms)
- Logging: track conflicts and sync failures for debugging
- Testing: test offline, with artificial latency and simulated disconnections
Frequently asked questions
1. What is the difference between Replicache and other synchronization libraries?
Replicache is specifically designed for local-first: the client is the primary source of truth, synchronization is probabilistic and conflicts are resolved automatically. Other libraries (Firebase Realtime DB, Supabase) center the server as source of truth. Replicache guarantees offline-first with immediate UX.
2. How does Reflect improve experience over manual observables?
Reflect eliminates observer pattern boilerplate. Instead of manually creating Observable<State>, Reflect makes native JavaScript objects reactive with decorators. Binding is automatic and code is more readable.
3. What happens if two clients modify the same data simultaneously?
Replicache applies the mutation locally (optimistic), queues it, then synchronizes with the server. The server validates both mutations, applies them in temporal order, and resolves the conflict. The client receives the resolved update and syncs its local state.
4. Is Replicache suitable for large-scale applications?
Yes, but requires scalable infrastructure. Replicache handles up to ~100k objects locally with IndexedDB. For larger apps, distribute multiple backend instances and use robust database. Monitoring is essential.
5. How much does deployment complexity increase?
Not much. Just add two endpoints (/api/push and /api/pull) to your backend. The rest of the application remains unchanged. Major complexity is in testing offline and managing robust synchronization.

Commenti
Caricamento commenti...
Sign in to comment