Building a Real‑Time Sync Engine on Postgres: What Works, What Doesn’t
Share this article
The Quest for a Plug‑and‑Play Sync Layer
A recent Hacker News thread (source: https://news.ycombinator.com/item?id=46374814) captured a common pain point for modern web developers: how to give an application the feel of a native app—instant updates, optimistic UI, and offline resilience—while still using a traditional relational database such as Postgres on AWS RDS. The poster lists a handful of contenders—Convex, ElectricSQL, Zero, Liveblocks—yet laments that each feels “modular” and falls short in key areas.
The underlying problem is deceptively simple: take a Postgres database, expose it to a browser or mobile client, and keep the client state in sync with the server with minimal latency and maximum developer ergonomics.
What a Real‑Time Sync Engine Must Deliver
- Native Postgres backend – the engine should piggyback on an existing RDS instance, leveraging its ACID guarantees.
- Backend‑centric query execution – developers want to write queries in the same language as their business logic (Python/SQLAlchemy, Node/Drizzle, etc.) and have the sync layer translate them to client‑friendly subscriptions.
- Schema‑first declaration – reuse existing ORM or schema definitions so that the sync layer stays in sync with the database schema.
- Developer‑friendly SDK – a thin wrapper that lets a React or Svelte component subscribe to a query and receive incremental updates.
- Scalability knobs – support for connection pooling, sharding, read replicas, and eventual consistency across data centers.
Why Most Off‑the‑Shelf Solutions Fall Short
| Solution | Strength | Limitation | Why it feels modular |
|---|---|---|---|
| Convex | Built‑in auth, serverless functions | Requires Convex‑specific schema language | The engine is a separate runtime, not a thin layer over Postgres |
| ElectricSQL | Direct Postgres integration | Limited to a single logical replication stream | Separate sync service that must be maintained alongside the app |
| Zero | Zero‑config, optimistic UI | Relies on a proprietary event bus | The client and server are tightly coupled to Zero’s runtime |
| Liveblocks | Real‑time collaboration primitives | No direct SQL query support | Provides a general‑purpose sync layer, not a database‑centric one |
In short, these tools either replace Postgres with a custom data store, or add a layer that requires its own deployment and management. Neither satisfies the desire for a single, cohesive stack that uses the same database, ORM, and query language.
The Architecture of a “Postgres‑First” Sync Engine
A viable approach is to build a thin sync service that sits between the database and the clients, using Postgres’s own change‑data‑capture (CDC) mechanisms. The core components are:
- Logical Replication Slot – Postgres streams every DML operation as a logical change.
- Change Processor – A lightweight worker (e.g., a FastAPI endpoint or a background task) that reads the replication stream, applies conflict‑resolution rules, and pushes updates to clients via WebSockets or Server‑Sent Events.
- Subscription API – Clients subscribe to a query by sending a SQL‑like statement; the server validates the query, sets up a subscription ID, and streams rows that match the predicate.
- Schema Registry – A JSON or Protobuf schema that mirrors the ORM definitions, ensuring that the client knows the shape of the data.
- Auth & Permissions – A middleware layer that intercepts subscription requests and enforces row‑level security.
Sample Flow
# FastAPI endpoint for subscription
@app.post("/subscribe")
async def subscribe(req: SubscribeRequest, user=Depends(auth)):
# Validate the SQL query against the user's permissions
stmt = validate_sql(req.query, user)
# Register the subscription and return an ID
sub_id = await subscription_manager.register(stmt, user)
return {"subscription_id": sub_id}
On the client side:
// React hook that subscribes to a query
const useRealtimeQuery = (query) => {
const [rows, setRows] = useState([]);
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/subscribe");
ws.onopen = () => ws.send(JSON.stringify({ query }));
ws.onmessage = (e) => setRows(JSON.parse(e.data).rows);
return () => ws.close();
}, [query]);
return rows;
};
This pattern keeps the database untouched, reuses existing ORM schemas, and gives the client an API that feels like a normal query but is backed by real‑time updates.
Scaling Challenges
| Challenge | Why it matters | Mitigation |
|---|---|---|
| Connection Pooling | Each client opens a WebSocket; the backend must multiplex many streams over a limited number of DB connections. | Use a pool of logical replication slots or a single slot with a fan‑out buffer. |
| Sharding / Multi‑AZ | Data is spread across replicas; a change in one shard must be visible to all clients. | Replicate the subscription state across shards or use a global logical replication layer like Debezium. |
| Replication Lag | Network latency can cause clients to see stale data. | Implement a write‑through cache or optimistic UI with rollback on conflict. |
| Conflict Resolution | Two clients editing the same row concurrently. | Adopt last‑write‑wins or Operational Transformation depending on the use case. |
Existing Tools Worth a Second Look
- Postgres Logical Replication + WebSockets – A DIY approach that gives full control; many open‑source projects (e.g.,
pgoutput+socket.io) illustrate the pattern. - Supabase Realtime – Built on top of Postgres’s replication, it offers a subscription API but requires a Supabase instance.
- Hasura – GraphQL‑first, but its real‑time engine is essentially the same pattern; it can be used with a custom backend if you prefer SQL over GraphQL.
- ElectricSQL – While it adds a sync layer, its open‑source core can be forked and adapted to a Postgres‑first workflow.
Bottom Line
A truly “Postgres‑first” real‑time sync engine is not a silver bullet, but it is achievable with a modest architectural investment. By leveraging logical replication, a lightweight subscription API, and careful attention to scaling, developers can give their web apps the feeling of a native experience without abandoning the comforts of a relational database. The key is to keep the sync layer thin and composable with existing tools, rather than replacing the database or the ORM.
“The biggest barrier isn’t the technology; it’s the mindset that a sync engine must be a separate monolith.” – Anonymous contributor on Hacker News.