Discoverwhy building custom REST APIs for offline sync is a trap. Learn how sync engines like RxDB, Electric SQL, and Replicachedeliver zero-latency experiences.

Imagine you are on a train, working on a critical project update in a browser-based task manager. The train goes through a tunnel. Your connection drops. You click "Save" on your changes, and theinterface freezes. A loading spinner spins indefinitely, eventually failing with a generic network error. When your connection returns, you findthat your updates were lost, or worse, they overwrote a colleague's changes because the application had no way toreconcile the offline state.
This is the classic failure mode of the traditional web application. For decades, we have builtweb applications under the assumption that the network is a constant, reliable utility. We write frontend code that sends a REST request ora WebSocket message for every user action, waiting for the server to say "OK" before updating the screen. If the network isslow, the application is slow. If the network is down, the application is broken.
Building a modern local first web appchanges this paradigm completely. In a local-first architecture, the client-side database is the primary source of truth. Whena user makes a change, the application writes immediately to a local database running inside the browser. The user interface updates instantlywith zero network latency. In the background, a dedicated synchronization layer manages the complex task of uploading those changes to the serverand downloading updates from other clients.
Historically, developers attempted to build these synchronization systems themselves using custom REST endpoints or WebSocket polling. Today, specialized sync engines have made custom sync layers obsolete. Let us explore why building your own sync protocol is a dangeroustrap, and how modern engines like RxDB, Electric SQL, and Replicache offer a far superior path forward.
##The Traditional REST and WebSocket Bottleneck
In a traditional web application, every user interaction is a round-trip journey.When a user clicks a button to complete a task, the frontend dispatches an HTTP POST request to an API endpoint. Thedatabase processes the write, returns a success code, and the client updates the user interface to show the completed task.
Thismodel introduces a fundamental bottleneck: the network round-trip time (RTT). In ideal conditions on a high-speed fiber connection, this might take 50 milliseconds. On a mobile 4G or 5G connection with moderate congestion, thatlatency can easily spike to 300 milliseconds or more. To the human eye, any delay over 100milliseconds feels sluggish. If your application requires multiple sequential API calls to complete a workflow, the user experience rapidly degrades.
WebSockets are often proposed as a solution to this latency. By keeping a persistent TCP connection open, WebSockets eliminate the overheadof establishing HTTP connections for every request. However, WebSockets do not solve the fundamental problem of network dependence. If the clientis offline, the WebSocket connection drops. The application must then queue outgoing messages, manage reconnect logic, and handle the inevitableflood of backlogged data when the connection is restored.
traditional backends are designed for stateless operations. Theyreceive a request, validate it against the current database state, write the change, and forget about the client. When youtry to scale this to support real-time collaboration or offline queueing, your server-side logic must suddenly become state-aware. You must track which client has seen which version of the database, leading to complex tracking tables and ballooning databaseread operations.
To understand why custom APIs fail at sync,we must first define what a true local-first architecture looks like. Local-first is not simply "offline support"bolted onto an existing web app. It is a complete inversion of how data flows through your system.
A true local-first applicationadheres to several core principles:
By shifting the primary data store to theclient, the network is downgraded from a critical runtime dependency to an asynchronous transport medium. This drastically simplifies the frontend rendering logic. Insteadof managing complex loading states, retry loops, and optimistic UI updates, your frontend components simply subscribe to a local database query. Whenthe database changes (either from a local write or a background sync pull), the UI automatically rerenders the new state.
##The Hidden Costs of Custom Sync Architecture
When teams decide to implement local-first capabilities, they often begin with the beliefthat synchronization is a simple problem. They plan to store updates in localStorage, send them to a /sync RESTendpoint when the network is available, and use an updated_at timestamp to determine which record is newer.This approach quickly falls apart under real-world conditions. Let us examine the technical hurdles that emerge when you attempt to builda custom sync architecture:
In a standard database, you can delete a row using asimple DELETE statement. In a synchronized database, you cannot do this. If Client A deletes a row locally while offline, the server needs to know about that deletion during the next sync. If the server simply deletes the row from its database, Client B (who has not synced yet) will not know the row was deleted. When Client B syncs, theymight upload their local copy of the row, effectively resurrecting the deleted data. To prevent this, you must implement softdeletes using tombstone records (e.g., setting an is_deleted flag and a deleted_at timestamp), which complicates every single query in your system.
To perform an efficient sync, theclient must ask the server only for data that has changed since the client's last successful sync. This requires tracking synctokens, transaction IDs, or high-water marks. If a sync session is interrupted halfway through, you must handle partialupdates. If you do not write transactional boundaries into your sync protocol, you will end up with inconsistent states, such asan invoice item existing without its parent invoice record.
In a traditional web app, schemamigrations are straightforward. You run a migration on your single cloud database, deploy the new backend code, and users get the new version ontheir next page refresh. In a local-first application, you have thousands of databases running on users' devices. Ifyou change your database schema, you must write migration scripts that run locally inside the user's browser, transforming their offlinedata to the new schema without losing their unsaved changes.
Instead of spending months building and debugging a custom synchronization protocol, modern teams use dedicated sync engines. These tools providethe client-side database, the server-side connectors, and the synchronization protocol out of the box. Three of the most prominent optionsin the ecosystem today are RxDB, Electric SQL, and Replicache.
Choosinga sync engine rxdb provides a powerful, client-side database designed specifically for JavaScript applications. It is highly flexible,allowing you to use different underlying storage engines, such as IndexedDB, OPFS, or even memory-only stores. RxDB is built around RxJS observables, meaning your UI can easily subscribe to queries and automatically update when data changes. Forsync, RxDB uses a replication protocol that can connect to any CouchDB-compatible endpoint, or any custom GraphQL/REST endpoint that conforms to its replication interface.
Integrating an electric sql database into your architecture provides a bridgebetween Postgres in the cloud and SQLite in the client browser. Electric SQL uses Postgres's native logical replication (the Write-Ahead Log, or WAL) to stream changes in real-time between your central database and an in-browser SQLite database running via WebAssembly.It handles the complex translation of relational data schemas and permissions, allowing you to write standard SQL queries on the client that stayperfectly in sync with your backend Postgres instance.
Replicache takes a slightly different, mutation-based approach. It is not tied to a specific database technology. Instead, it acts as a client-side key-value store thattracks mutations (changes) locally. When the client is offline, mutations are queued. When the connection returns, Replicache plays those mutations back to your existing backend server via simple HTTP endpoints that you define. This makes Replicache incrediblyeasy to integrate into existing applications, as you do not need to replace your entire database infrastructure to use it.
The most difficult aspect of any distributed database system is conflict resolution. What happens when User A and User B both update the same task title while offline, and then both sync at the same time?
Custom sync engines usually rely on a naive Last-Write-Wins (LWW) strategy. They compare the timestamps ofthe incoming writes and keep the one with the latest timestamp. While simple, LWW has major drawbacks. Client clocks are notoriouslyunreliable and can be out of sync by minutes or hours. Even with accurate network time synchronization, LWW results in silentdata loss. If User A updates the description of a document and User B updates the tag of the same document a millisecond later,User A's changes are completely wiped out.
Professional sync engines solve this using more sophisticated mathematical models, primarily Conflict-free Replicated Data Types (CRDTs) and structured mutation playbacks.
Client A (Offline) --------> EditTitle -----------------\
\---> Merge (CRDT) -> Consistent State
Client B (Offline) --------> Edit Description ------------/
CRDTs are data structures that can be updated independently and concurrently without coordination. Whenreplicas receive different updates, they can resolve them mathematically to arrive at the exact same state, regardless of the order in which the updates arrived.
There are two main types of CRDTs:
Electric SQL uses state-based CRDTs under the hoodto handle conflict-free replication of relational tables. It translates your relational database schema into equivalent CRDT representations, ensuring that concurrentedits to different columns of the same row do not overwrite each other.
Replicache, on the other hand,avoids CRDTs by using client-side speculative execution and server-side mutation reordering. When a mutation is made,Replicache runs it immediately on the client's local cache. When syncing, it sends the raw mutation command (e.g., updateTaskTitle(id, newTitle)) to the server. The server runs the mutations sequentiallyin a single transaction, resolving conflicts using your standard backend business logic, and then streams the final, authoritative state back tothe client.
To understand how clean a local-first codebase can be, letus look at how you initialize and query a local database using RxDB. In this example, we will set up a local databasewith an IndexedDB storage provider and define a reactive query that updates our UI whenever the data changes.
First, install the necessarypackages:
npm install rxdb rxjs rxdb-providers
Next, we initialize the database and createa collection for managing project tasks:
import { createRxDatabase, addRxPlugin } from 'rxdb';import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
// Add theRxDB query builder plugin for mango queries
import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder';
addRxPlugin(RxDBQueryBuilderPlugin);
// Define the schema for our tasks
const taskSchema= {
title: 'task schema',
version: 0,
primaryKey: 'id',type: 'object',
properties: {
id: {
type: 'string',maxLength: 100
},
title: {
type: 'string'},
isCompleted: {
type: 'boolean'
},
updatedAt: {type: 'number'
}
},
required:['id', 'title', 'isCompleted', 'updatedAt']
};
async function initializeDatabase() {
// Create the database using Dexie (IndexedDB wrapper) as the storage engine
const db = await createRxDatabase({
name: 'taskdb',
storage: getRxStorageDexie()
});
// Add the tasks collection
awaitdb.addCollections({
tasks: {
schema: taskSchema
}
});
return db;}
Once initialized, we can write data directly to our local database. Because this write is local, it completesalmost instantly:
async function addTask(db, title) {
await db.tasks.insert({id: crypto.randomUUID(),
title: title,
isCompleted: false,
updatedAt: Date.now()
});
}
To display these tasks in our application, we do not need to makeAPI calls or manage state variables. We simply subscribe to a query. RxDB uses RxJS observables to push new data whenever theunderlying database changes:
function subscribeToTasks(db, callback) {
const query = db.tasks.find({
selector: {},
sort: [{ updatedAt: 'desc' }]
});
const subscription = query.$.subscribe(tasks => {
// This callback runs immediately, and then again
// every time a taskis added, updated, or synced
callback(tasks);
});
return subscription;
}```
If you configure RxDB's replication plugin to point to a server-side endpoint, the background sync process will automaticallypush local writes to the server and pull new tasks down. The subscription we wrote above will automatically trigger and update our UI withthe synced data, without a single line of manual update code in our application components.
## Bridging Postgres to theClient with Electric SQL Database
While RxDB works beautifully for document-centric workloads, many enterprise applications are built on relational databases, specifically PostgreSQL. This is where an electric sql database shines. Electric SQL allows you to treat your frontend as a thin,reactive replica of your server-side Postgres database.
The architecture of an Electric SQL installation consists of three main parts:
1.**PostgreSQL Database:** Your standard cloud-hosted database, which acts as the ultimate source of truth.
2. **Electric Sync Service:** A lightweight Elixir-based service that sits next to your database. It reads the Postgres logical replication stream andmanages WebSocket connections to client applications.
3. **Electric Client:** A client-side library that wraps a local WebAssembly-powered SQLite database running inside the user's browser.
+-------------+ Logical +------------------+ WebSockets +-----------------+ | Postgres | Replication stream | Electric Sync |(Sync Protocol) | Local SQLite | | Database | ----------------> | Service| <----------------> | (WASM/OPFS) | +-------------+ +------------------++-----------------+
To use Electric SQL, you write standard SQL schemas on your backend. You then usethe Electric CLI to generate client-side TypeScript bindings. This generation step inspects your Postgres schema and creates type-safe queryinterfaces for your frontend.
Here is what querying your synchronized SQLite database looks like using the Electric client:
```typescriptimport { electrify } from 'electric-sql/wa-sqlite';
import { makeElectricContext } from 'electric-sql/react';
// Initialize the Electric client with a local SQLite database file
const config = {url: 'https://your-electric-sync-service.com'
};
const conn = await db.open('local.db');
const electric = await electrify(conn, schema, config);
// Sync only thedata the current user has access to
const shape = await electric.db.tasks.sync({
where: {userId: currentUser.id
}
});
// Wait for the initial data shape to download from the server
await shape.synced;
// Query the local SQLite database reactively
const tasks = await electric.db.tasks.findMany({
where: {
isCompleted: false
}
});
The concept of "shapes" is central to Electric SQL. A shape defines a subset of your database (a table, its relations, and specificfiltering criteria) that a particular client needs. This prevents the client from downloading your entire production database. Instead, they sync only theirspecific workspace, team data, or user records. The Electric Sync Service monitors Postgres for any changes that match this shape andstreams them to the client instantly.
The difference in developer experience and application performance between a customREST/WebSocket architecture and a dedicated sync engine is stark. Let us break down these differences across key metrics.
When building a custom sync system, every new feature requires coordinating changes across multiple layers of your stack:
1.You write a migration for your server database.
2. You build a REST endpoint to expose the new fields.
3.You update your API client on the frontend.
4. You write custom caching logic to store the new data in localStorage.5. You write conflict-resolution code to handle concurrent edits to those fields.
With a sync engine, you defineyour schema once. The sync engine handles the replication, local storage, schema validation, and conflict resolution automatically. Developers canfocus entirely on building user interfaces and business logic.
In a standard web application, readingdata requires a network call, resulting in a latency of 50ms to over 1000ms. Writesrequire waiting for server confirmation before updating the UI, leading to a sluggish user experience.
With a local-first syncengine:
| Metric | Traditional REST App | Local-First Sync Engine |
|---|---|---|
| Initial Page Load Query | Needs network fetch (100ms - 2000ms) | Instant from local cache (1ms - 10ms) |
| Data Modification | Blocks UI until server confirms (200ms+) | Immediate optimistic local update (<1ms) |
| Offline Capability | Broken / "You are offline" screens | 100% functional, queues updates |
| Sync Logic Complexity | High manual overhead per endpoint | Zero manual overhead (handled by engine) |
Custom sync implementations often rely on pollingor constant WebSocket heartbeats, which drain mobile device batteries and consume unnecessary cellular data. Modern sync engines use highly optimized protocols like deltacompression and binary serialization formats. They only send the exact bytes that changed, and they automatically pause synchronization when the application goes to thebackground or when the device's battery is low.
While local-first architecturesoffer incredible benefits for user experience and developer velocity, they are not a silver bullet. There are specific scenarios where a traditionalserver-centric REST architecture is still the correct choice.
Browser storage isnot infinite. IndexedDB and SQLite in WASM are typically limited by the browser's storage quota, which is usuallya percentage of the user's available disk space. If your application requires users to query terabytes of historical log data or searchthrough millions of global records, you cannot sync that data to the client. These applications must rely on server-side searchindexes and server-side query execution.
In a local-firstapplication, data is decrypted and stored on the user's physical device. If you are building an application that handles highly sensitive financial data, medical records subject to strict HIPAA regulations, or classified government intelligence, storing that data locally on a personal laptop or mobilephone may violate security policies. While client-side databases can be encrypted, the risk of physical device compromise makes server-centric architectureswith strict session management much easier to secure.
Certainbusiness logic requires absolute consistency across the entire system. For example, if you are building an airline ticket booking system, youcannot allow two users to book the same seat. If both users are offline and book the same seat, a local-first conflictresolution engine would resolve the conflict later, but one of the users would end up with a canceled ticket. For transaction-heavy systems wheredouble-booking is catastrophic, writes must go through a centralized coordinator (the server database) before they can be confirmed.## Choosing the Right Path for Your Next Project
If your application does not fall into those specific edge cases, adoptinga local-first architecture is one of the most impactful decisions you can make for your project's longevity and user satisfaction.When choosing between sync engines, consider your existing backend infrastructure:
Stop building custom REST synchronization endpoints. Stop debuggingrace conditions in your WebSocket connection handlers. Embrace the local-first movement, let a dedicated sync engine handle the heavy lifting ofdata replication, and build web applications that are instantly responsive, completely reliable offline, and a joy to use.
Key takeaways
- Zero Latency: Local-first web apps read and write directly to an in-browserdatabase, eliminating network latency and providing sub-millisecond UI updates.
- Avoid Custom Sync: Building a custom synchronizationprotocol using REST or WebSockets introduces massive development overhead, especially when handling soft deletes, schema migrations, and conflict resolution.
- Leverage Modern Engines: Specialized sync engines like RxDB, Electric SQL, and Replicache handle the complexmath of synchronization and conflict resolution out of the box.
- Choose by Infrastructure: Select Electric SQL for PostgreSQL-centric applications, Replicache for integration with existing APIs, and RxDB for highly reactive, document-based workloads.
Ifyou are planning a project like this and want to discuss how to implement a local-first architecture using modern sync engines, weare happy to talk it through.
01 · RelatedA step-by-step engineering case study of an API credential exposure and how modern product teams automate secret detection and rotation.
Read post
02 · RelatedBeyond OpenAI API: Building Local LLM Pipelines for Privacy Sending customer data to a third-party APIis a risk that many startups can no longer afford to take. Whether you are handling medical…
Read post
03 · RelatedDiscover why developers who combine clean code with product thinking and UI/UX empathy rise fasterto technical leadership positions.
Read postWe will reply in plain English within one business day, NDA on request. Discovery call is free.