ZeBridge, tenant-scoped local-first apps with Postgres and NATS
User case: A field worker opens an app with no signal. They can still check assigned jobs, update a delivery, and keep working. When the phone reconnects, the pending write reaches PostgreSQL, which accepts or rejects it under the application’s database rules. Other authorized devices receive the resulting changes. That’s the problem ZeBridge is built to solve. Local SQL, with PostgreSQL in charge ZeBridge keeps selected PostgreSQL data in a local database on each client. The client library can use SQLite, PGlite, DuckDB, or PostgreSQL, depending on its runtime. Apps query that local replica with SQL and can queue mutations while offline. NATS carries change events and mutation requests between clients and the hub. PostgreSQL remains authoritative: a local optimistic change is not a final write decision. Once connected, the client receives the outcome, and the replica follows the resulting change. A tenant is an authorization scope (a column in your schema), usually a customer organization or workgroup whose devices share data. It isn’t a client-chosen SQL filter (ie not a WHERE): PostgreSQL rules and NATS credentials constrain what each identity can access. This model is aimed at B2B models, shared operational datasets—fleets, depots, field teams, and similar apps—rather than consumer apps built around a separate tenant for every user. The client library provides one interface to read the local replica, submit mutations, and query remote responders such as routing or analytics services. Optional regional NATS leaves serve snapshot caches near devices and host those services. How it works Three parts: the hub, which you install; your backend, which signs users in and issues invites; and the app, which uses the client library on the local database you choose. Services your apps can ask, such as routing or analytics, are optional and run as separate processes on the same library. flowchart LR subgraph ZB["ZeBridge: you run one hub"] direction LR PG[(PostgreSQLlocal or managed)] B[Bridge] B N[NATS JetStream] A[Mutation admission] N end subgraph App["Client App"] N C[libzb or zb-client-ts+ local replica] end The app and backend Your backend uses its existing sign-in flow to identify the user and choose their tenant. It then asks PostgreSQL, through a restricted invite role, for a one-time enrollment code. The client exchanges that code for its own identity and tenant-scoped NATS credentials. You still build and operate your authentication flow, database rules, and ZeBridge hub. The difference is that you don’t build a separate sync endpoint or write API for the client library’s mutations. For example, a Node backend: // Your backend, after it has signed the user in. Its database login holds only zebridge_inviter. const { rows } = await db.query( "SELECT zebridge_invite($1, $2, interval '1 hour') AS code", [user.id, user.tenant], ); return { invite: rows[0].code }; // in the login response The returned {invite: } is used by the client app. The app The app passes the code to the library on its first start. The library enrols the device, stores its identity, seeds its replica and follows changes; later the code is rotated by the library, which renews the device's credentials by itself. A browser client can read its local replica and submit a mutation like this: npm install @zebridge/client import { ZeBridge } from "@zebridge/client"; // From your own login flow; only the first start needs it. const { invite } = await signIn(email, password); const zb = new ZeBridge({ bridgeUrl: "https://bridge.example.com", invite, tables: ["orders"], }); zb.onChange("orders", () => refreshOrders()); zb.onVerdict((verdict) => showWriteOutcome(verdict)); await zb.connect(); // SQL reads the local replica, including while offline. const orders = await zb.query( "SELECT id, status FROM orders WHERE status = ?", "pending", ); // Mutations apply optimistically and enter the durable outbox. if (orders.length > 0) { await zb.mutate( "orders", "UPDATE", { id: orders[0].id }, { status: "done" } ); } The API has a few primitives zb.* lie zb.query() for local reads. Thenzbmutate() applies an optimistic local change, queues it, and follows it through retries to PostgreSQL’s verdict. A completedzb.mutate() call does not mean the server accepted the write. These work offline. The TypeScript client drives its own synchronization loop. With native libzb, the host drives sync() and poll(); see the binding's example for its threading or worker model. Services can use the same network Remote responder requests — for example, a route calculation — need a network connection; the local query and mutation path works offline. ZeBridge can connect apps to responder services over NATS. For example, a depot app can get a route from a nearby Valhalla service while reading truck locations from its local replica. Multiple responders can share a NATS queue group, and regional leaf nodes can host responders close to users. The client and service paths serve different needs: local SQL supports offline work; a responder request depends on a reachable service. The depot example (https://bridge.zebridge.eu/demo) shows these pieces working together. Some comparisons with PowerSync and ElectricSQL ZeBridge, PowerSync and Electric's Postgres Sync all sync PostgreSQL data to clients. PowerSync and ZeBridge provide client libraries built around local SQLite replicas; Electric exposes HTTP Shapes that a client library or integration materializes into a local store. They differ in where the write path lives, what you run, and how applications connect to other services. Read-side seed comparison (PG -> local replica) I ran a 50,000-row seed comparison against one local PostgreSQL 17 instance in OrbStack. 10 clients per system started with empty SQLite databases. The timer included transfer and local materialization, and stopped once the rows were queryable. Every replica matched the same SHA-256 checksum. Client path Median per-client seed, two rounds Slowest client, two rounds ZeBridge / libzb 3.076 s / 2.959 s 3.094 s / 2.963 s PowerSync / Node SDK 12.76 s / 12.99 s 13.00 s / 13.14 s Electric / TanStack DB / Node SQLite 12.052 s / 11.481 s 18.548 s / 17.636 s On these runs, ZeBridge’s median seed time was about 4 times faster. These are client-seeding results, not general throughput figures, but ran on the same machine. The client stores were cold, but the service-side setup was warm: ZeBridge’s snapshot chain was ready, PowerSync had a one-client readiness seed, and Electric’s second round reused the Shape created during its first. This benchmark measures read-side seeding, not write behavior or equal per-user authorization. PowerSync used a fixed test-tenant predicate; the run doesn’t establish its per-user access controls. The full comparison records the setup and caveats in the repo. Notes on schemas ZeBridge derives replica schema from the enabled PostgreSQL tables and includes its mutation path. PowerSync’s SDK expects the app to declare its local table schema. Electric syncs HTTP Shapes that a client integration materializes into a local store; the TanStack Electric adapter requires a key accessor, while its validation schema is optional. Electric Sync is read-path sync: writes go through your API. A separate 500-client ZeBridge–PowerSync test This was a different test, run against Supabase with a 10,000-row table. 500 lightweight clients shared a 6-vCPU, 12-GB host with the services. The sync timings below measure delivery, not local database apply. ZeBridge PowerSync Delivery: 500 clients starting cold receive the 10,000-row table, median (excluding local apply) 5.3–5.7 s: one shared compressed snapshot, 0.23 MB a client 61–76 s: every operation of the bucket, 4.63 MB a client Writes, offered at 1,000/s / 2,000/s 990/s, verdict median 0.69 s / 1,572/s 879/s through PostgREST, acknowledgement median 4.8 s / 1,702/s A write reaching every client, one write to each of the 500, median 0.4–0.8 s 0.7–1.6 s For writes, both systems committed the rows. ZeBridge submitted mutations through NATS; PowerSync uploaded through PostgREST, configured with 30 connections. Offered write rate ZeBridge committed / median verdict PowerSync committed / median acknowledgement 500/s 536/s / 0.34 s 407/s / 7.4 s 1,000/s 990/s / 0.69 s 879/s / 4.8 s 2,000/s 1,572/s / 5.0 s 1,702/s / 6.4 s At 500 and 1,000 offered writes per second, ZeBridge committed more writes and returned verdicts sooner in this test. At 2,000/s, PowerSync committed more writes, while ZeBridge’s median verdict was faster. The PowerSync backend was provided for the benchmark; its principal header wasn’t independently validated, so this isn’t an end-to-end authorization comparison. Electric wasn’t measured in this write test. Its sync service handles the read path; writes go through the application’s API. Code and examples https://github.com/ndrean/ZeBridge
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to