Web Development

Electric vs Zero vs PowerSync vs Convex: The Hard Part Was Never Sync, It Was Row-Level Auth

We've put four sync engines in front of client databases this year. The offline demo took a day in every one of them. What took eleven weeks was answering which rows a given user is allowed to keep on their device — and what happens to that copy when the answer changes.

KAKabir AnandLead Developer
10 min read
Isometric illustration of a central database cylinder feeding four pipes out to laptops and phones, each pipe passing through a filter gate that lets only some data through

A client asked for offline mode on their field-ops dashboard. We had the happy path running in a day: local writes, instant reads, a little sync indicator in the corner, the demo everyone claps at. It shipped eleven weeks later. Almost none of that time went on sync. It went on the fact that a technician in Pune must not end up with the Chennai region's job rows sitting in a SQLite file on their tablet — and that the answer to which rows changes every time someone switches teams.

We've since run four of these in client work: Electric, Zero, PowerSync and Convex. The quickstarts are all genuinely good and all roughly equivalent. What separates them in production is much less exciting than the demos suggest — where the rule about who can see which rows is written, who evaluates it, and what the device does when it stops being true.

The demo is the easy half

Every engine on this list will get you a reactive query and an optimistic write before lunch. That part is solved, and it is solved well enough that comparing them on it is a waste of an afternoon. The interesting differences sit one layer down, in four jobs that exist whether or not the tool you picked has an opinion about them.

Four jobs somebody has to own

  • Partitioning — which subset of the database a client may hold, expressed somewhere the server evaluates, not as a filter in your React code.
  • Write path — where an optimistic local write becomes authoritative, and which side runs the validation your API already has.
  • Revocation — what happens to rows already on a device when access is removed, a user changes team, or someone leaves.
  • Schema drift — how a client that has been offline for three weeks catches up with a table that gained a NOT NULL column while it was away.

Read that list again as a procurement question rather than an architecture one: for each engine, how many of the four does it own, and how many does it hand back to you with a doc page and good intentions? That framing predicted our outcomes better than any benchmark or feature matrix did.

Flat vector diagram of a stream of small squares meeting a sorting junction and splitting into three separate lanes that end at three device outlines
Sync is the stream. The junction — who gets which lane — is the part you are actually buying.

Four engines, four places the auth rule lives

The useful way to tell these apart is to ask where the sentence "this user may read this row" physically exists in your codebase. In one it's a proxy you write. In one it's a predicate in the schema. In one it's a YAML file describing buckets. In one it's a function on a server that was always going to run there. Everything else about the four follows from that answer.

Electric — a read pipe over your Postgres, and it does not want your writes

Electric syncs shapes: a table plus a where clause, streamed to the client over plain HTTP. It is deliberately read-path only. Writes go through the API you already have, which on a brownfield app is the single best property on this page — your existing handlers, validation, auditing and rate limits keep working, and sync becomes an addition rather than a rewrite.

The catch is that a shape definition arrives from the client, so you must not expose the shape endpoint directly. You put your own gatekeeper in front, take the client's request for a logical collection, and rewrite the where clause from the session. That is maybe forty lines. But it is forty lines you own, and nothing in the library will tell you if you forget them on one route out of nine.

We reach for Electric when there's an existing Postgres app with real API surface and the ask is "make these screens instant," not "make this app work on a plane."

Zero — the query runs on the client, the permission runs on the server

Zero moves the query itself to the client: you write ZQL, it answers from a local cache and backfills from the server as needed. Permissions are declared in the same schema as the data, as predicates the server evaluates on read and write, which is the cleanest answer to our four jobs that anyone on this list gives. When the auth rule lives beside the table definition, it is much harder for a new screen to quietly skip it.

Its write story is the other reason to look: mutators can run locally for the optimistic result and again on the server as the authority, so the rule is written once and enforced where it counts. The cost is maturity. It is the youngest of the four and you will feel that at the edges — tooling, error messages, and the amount of prior art available when something behaves oddly at 2am.

PowerSync — buckets are the auth model, and that is a bigger commitment than it looks

PowerSync gives the client a real SQLite database and defines what lands in it through sync rules: bucket definitions, written as SQL, that partition rows by whatever parameter identifies the user. Writes queue locally and are uploaded to your own backend, so like Electric it leaves your API in charge of authority. Offline is not a mode here, it is the default posture, and for genuinely disconnected field work that matters.

Buckets are also where the sharp edge is. They are your authorization model, expressed in a language whose expressiveness is the ceiling on how complicated your sharing rules are allowed to get. We hit that ceiling on an app with per-project member lists plus a separate contractor role, and the fix wasn't clever rule-writing — it was adding a materialised membership table to the database purely so the buckets could be defined in one join. That was the right call and it should have been a day-one decision, not a week-six one.

Convex — not a sync engine, a backend that was reactive from the start

Convex doesn't sit in front of your database; it is the database, plus the functions around it. Queries are reactive by construction and auth is ordinary code inside a function, which means it is the only option here where partitioning, writes and validation are all the same mechanism and there is nothing to forget to wire up.

That is a fair trade on a greenfield product and a non-starter on most of the work that reaches us, because it isn't a layer you add to an existing Postgres app — it replaces it. It also has the weakest story of the four for true offline, which people conflate with reactivity far too often. Live-updating is not the same as working on a train, and teams ask for the first while describing the second.

ElectricZeroPowerSyncConvex
Sits in front ofYour PostgresYour PostgresPostgres, MongoDB, MySQLIts own backend
Auth rule lives inA proxy you writeThe schema, beside the tableBucket sync rulesThe query function
Write pathYour existing APIMutators, client and serverUpload queue to your APIConvex mutations
Client storeIn-memory collectionsIn-memory cacheReal SQLite on deviceIn-memory cache
Offline for hoursNot the design goalPartialThe design goalWeakest of the four
Brownfield fitHighestHighHighReplacement, not addition
Where it bitesForgotten gatekeeperYoung ecosystemRule expressiveness ceilingYou've changed backends

Revocation is the test nobody runs

Here is the scenario we now run on day one of every sync project, before a line of UI is written. Log a user in. Let the client sync. Put the device in airplane mode. Remove that user's access to a project on the server. Bring the device back. Then go and read what is on disk.

In three of the four, the rows the user should no longer have arrive as deletes and disappear correctly — as long as your rule is expressed in a place the engine can re-evaluate. If your filter lives in the client query instead, nothing is revoked at all: the data is already local, the UI simply stops rendering it, and a file browser on the device still has it. That distinction is invisible in every demo and it is the entire difference between a filter and an access control.

app/api/sync/jobs/route.ts
1// The gatekeeper. The client asks for "jobs". It does not get
2// to say which jobs — we rewrite the predicate from the session.
3export async function GET(req: Request) {
4  const user = await requireUser(req);
5 
6  const upstream = new URL("/v1/shape", env.SYNC_ORIGIN);
7  upstream.searchParams.set("table", "jobs");
8  upstream.searchParams.set("where", "region_id = $1 AND archived_at IS NULL");
9  upstream.searchParams.set("params[1]", user.regionId);
10 
11  // Cursor params are the client's — they are resumption state,
12  // not authorization. Copy those two, never the filter.
13  for (const key of ["offset", "handle"]) {
14    const v = new URL(req.url).searchParams.get(key);
15    if (v) upstream.searchParams.set(key, v);
16  }
17 
18  return fetch(upstream);
19}

Twenty lines, and every one of them is load-bearing. Copy the client's query string wholesale to save time and you have built an endpoint that returns any row in the table to anyone with a session. We have reviewed exactly that code, written by a competent team, in a takeover project. It had been live for five months.

The rule we hold now

A sync route ships only with a test that asserts the negative: user A requests user B's partition, explicitly, by hand-crafting the request — and gets nothing back. Not a UI test, an HTTP test. Every engine here makes the positive case easy to demo and none of them make the negative case fail loudly on its own.

When a cache still beats a sync engine

About half the sync projects that reach us shouldn't be sync projects. The request is usually "it feels slow" or "it should work when the wifi drops in the warehouse," and both have cheaper answers than putting a replica of your database on every device. TanStack Query with sensible staleness, optimistic mutations and a service worker covers a surprising amount of this, and it adds no new failure modes to reason about.

  • Reads are broad, writes are rare— dashboards, reports, catalogues. A cache with a short stale window looks identical to the user and costs a fraction of the complexity.
  • The dataset per user is large and rarely revisited— syncing forty thousand rows so somebody can open three of them is an expensive way to be slower on first launch.
  • Sharing rules change constantly— if project membership shifts several times a day, every change is a re-partition, and you will spend more time on bucket invalidation than the feature was worth.
  • Offline means thirty seconds, not three hours— a lift, a tunnel, a bad handover. A retry queue solves this. A local database is a different product.

If the sentence about who can read a row exists in more than one place, one of those places is already wrong.

Internal review note, field-ops rebuild
Editorial photograph of brass mail-sorting pigeonholes on a worn wooden wall, some slots holding folded paper and others empty
Buckets, shapes, partitions, rules. Four names for deciding which slot a row is allowed to sit in.

How we'd choose now

The decision is almost entirely determined by two things you already know before evaluating anything: whether there's an existing Postgres app with an API worth keeping, and whether offline means minutes or hours. Feature comparisons rarely move it.

  • Electricwhen a live Postgres app needs instant screens and you want sync to be additive — your API keeps owning writes, and the only new thing you operate is a gatekeeper you can read in one sitting.
  • Zerowhen the app is read-heavy and collaborative and you want permissions declared once, next to the schema. Accept that you’re early, and budget for the days you spend being early.
  • PowerSync when devices genuinely go dark for hours and a real on-device SQLite is the requirement rather than a nice-to-have. Design the membership tables your buckets will need before you write the first rule.
  • Convexwhen it’s greenfield, the team is small, and the reactive-backend model is something you’re choosing on purpose — not something you’re sliding into because the offline demo was good.

And if none of that describes your project, the honest recommendation is the boring one: keep the API, add TanStack Query, and spend the eleven weeks on something a user asked for. We've un-built one sync layer and nobody who used the app noticed it was gone.

Whatever you pick, write the revocation test first. It takes an hour, it fails more often than anyone expects, and it is the only part of this decision that shows up in a security review rather than a retro.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.