Skip to content
Stribog

Local-First

All writing

ElectricSQL, PowerSync, Automerge: Picking a Sync Engine

ElectricSQL syncs reads only, PowerSync owns the offline write queue, Automerge merges with no server authority. Pick the boundary, then self-host it.

Stribog13 min read

Local-first stopped being a conference topic this year. FOSDEM 2026 ran a dedicated track — "Local-First, sync engines, CRDTs", under the motto "You own your data, in spite of the cloud". The projects underneath it now have production deployments, licence files worth reading, and, in one case, a new owner: on 11 August 2026, six days before this was written, Electric announced it is joining Databricks. That is the clearest demonstration available of why you evaluate a sync engine by what you can keep running yourself.

The Boundary, Not the Feature List

Every sync engine answers the same three questions. Where does a write become durable? Who decides when two writes disagree? And what does the client hold when the network is gone — a cache, a database, or the whole truth?

Electric: durability is yours, conflicts are yours, the client holds a synced subset for reading. PowerSync: durability is the server's, conflicts resolve at the source database, the client holds real SQLite plus a queue of pending mutations. Automerge: durability is wherever you put it, conflicts are resolved by the data structure, the client holds the whole document and its history.

None is more correct. They are different bets about which part of a distributed system your team can operate for ten years — a team already running Postgres as a first-class stateful workload is buying something different from a team whose product is a collaborative editor.

The heavily outlined box is the part the engine will not do for you — and it is in a different place in each lane. That, not throughput, is what you are choosing between.

Electric: Read-Path Sync, and Nothing Else

Electric's documentation is unusually blunt about its own scope: "Electric does read-path sync. It syncs data out-of Postgres, into local apps and services. Electric does not do write-path sync. It doesn't provide (or prescribe) a built-in solution for getting data back into Postgres." Instead, "you can implement writes in any way you like, using a variety of different patterns." Say that plainly, because comparison posts get it wrong — one currently indexed piece has Electric writing locally and syncing back through a CRDT layer. It does not. Adopting Electric means adopting an API you write, whose transactionality, authorisation and conflict rules you design and test.

The read path is genuinely good. The unit of partial replication is a Shape: "Electric syncs little subsets of your Postgres data into local apps and services. Those subsets are defined using Shapes." A shape names a root table — mandatory, and it must match a real table — plus an optional where clause and column projection. Shapes are built to be shared: "Many clients can sync the same shape. Multiple shapes can overlap."

Delivery is what matters operationally. The client-facing protocol is plain HTTP — "the primary, low level API for syncing data with Electric" — and shape responses "contain cache headers, including cache-control with max-age and stale-age and etag." The docs name Nginx, Caddy and Varnish alongside Cloudflare and Fastly. Initial-sync fan-out is absorbed by infrastructure you already run, and no proprietary wire protocol waits to be reverse-engineered later.

The documentation had not caught up when checked on 17 August 2026: the deployment guide still opened with a tip that "the simplest way to use Electric is via the Electric Cloud", and the Cloud page still carried a signup call-to-action beneath a banner announcing the move. An engineer can read those docs front to back this week and plan around a service the vendor has said is winding down. The repository still says "Electric is a read-path sync engine for Postgres" under Apache-2.0. The code is the durable artefact.

PowerSync: Server-Authoritative Buckets and a Durable Write Queue

PowerSync makes the opposite bet: it takes the write path and gives you an offline-capable client in exchange. "All synced data is grouped into buckets. A bucket represents a collection of synced rows, synced to any number of users." Buckets arrive against checkpoints — "a sequential ID that represents a single point-in-time for consistency purposes" — so a client never sees a torn view assembled from independent rows.

The client holds real SQLite plus a durable upload queue, which "stores three types of operations" generated from local statements: put, patch and delete. A second checkpoint kind exists purely to stop a client undoing itself — "write checkpoints are used to ensure clients have synced their own mutations back before applying downloaded data locally" — and that mechanism is also the constraint: "It's important that your API endpoint be blocking/synchronous with underlying writes to the backend source database ... don't place writes into something like a queue for processing later — process them immediately."

Ignore it and the failure is specific rather than vague. If the next checkpoint does not contain the mutation just uploaded, "those changes will be removed from the client. This could manifest as UI glitches for your end-users, where the changes disappear from the device for a few seconds and then re-appear." A UI-glitch class of bug, then, not silent permanent loss — but it still reads as data loss to the person holding the phone. If your platform convention is that every write endpoint publishes an event and returns, that convention and this engine disagree.

The two branches differ only in what the endpoint does before returning 200. Everything downstream is the engine behaving exactly as documented.
typescript
import { UpdateType } from "@powersync/web";
import type { AbstractPowerSyncDatabase } from "@powersync/web";

async function send(method: string, path: string, body?: unknown) {
  const res = await fetch(path, {
    method,
    credentials: "include",
    headers: { "content-type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}`);
}

// The uploadData half of a PowerSyncBackendConnector.
export async function uploadData(db: AbstractPowerSyncDatabase): Promise<void> {
  const tx = await db.getNextCrudTransaction();
  if (!tx) return;

  for (const op of tx.crud) {
    const row = { ...op.opData, id: op.id };
    const p = `/sync/${op.table}`;
    if (op.op === UpdateType.PUT) await send("POST", p, row);
    else if (op.op === UpdateType.PATCH) await send("PATCH", `${p}/${op.id}`, row);
    else if (op.op === UpdateType.DELETE) await send("DELETE", `${p}/${op.id}`);
  }

  // send() must not resolve until the row is committed to the SOURCE
  // database. Enqueue and return 202, and the next checkpoint arrives
  // without this mutation: the client deletes its own row.
  await tx.complete(); // never on a throw - the batch stays queued
}
The connector is where the constraint is either honoured or quietly broken. The comment marks the exact line where a queued endpoint becomes a UI flicker.

The partial-sync DSL has also moved. Sync Rules — "PowerSync's original system for partial sync, using YAML bucket definitions" — "remain supported for existing projects but are considered legacy." Sync Streams are now "the recommended approach to partial sync for both new and existing projects", a vendor-described superset adding "more expressive queries (including JOIN support), on-demand syncing". The pages carry no GA date or version, so treat the timeline as unstated rather than inferring one.

Automerge: Merge Without an Authority

Automerge is the third pole, and the one most often misdescribed as "no server needed". What it removes is not the server but the server's authority over merge: the CRDT converges locally, and a server, when present, is a relay. Its sync protocol "assumes a reliable in-order stream between two peers who are synchronizing a document" — a transport contract, not a resolution service.

The property that matters is stated precisely: "The only case Automerge cannot handle automatically, because there is no well-defined resolution, is when users concurrently update the same property in the same object." There, "Automerge picks one of the concurrently written values as the 'winner', and it ensures that this winner is the same on all nodes." Every replica agrees; nothing guarantees they agree on the value your business rules would have chosen. Make that executable:

typescript
import * as Automerge from "@automerge/automerge";
import assert from "node:assert/strict";

type Item = { sku: string; price: number };

// A genuine fork: both replicas share history to here, then
// diverge offline, without contact.
const origin = Automerge.from<Item>({ sku: "AX-7", price: 100 });
const a = Automerge.change(Automerge.clone(origin), (d) => {
  d.price = 90; // pricing applies a discount
});
const b = Automerge.change(Automerge.clone(origin), (d) => {
  d.price = 110; // finance applies an uplift
});

// Convergence: merge order does not change the outcome.
const aThenB = Automerge.merge(Automerge.clone(a), b);
const bThenA = Automerge.merge(Automerge.clone(b), a);
assert.equal(aThenB.price, bThenA.price);

// Correctness: the loser is retained, not resolved. getConflicts
// returns winner and losers, keyed by the writing operation.
const conflicts = Automerge.getConflicts(aThenB, "price") ?? {};
console.log("converged:", aThenB.price, "conflicts:", conflicts);

// Deciding WHICH price is right is your job, not the CRDT's.
console.log("by policy:", Math.min(...(Object.values(conflicts) as number[])));
Run with `npm i @automerge/automerge` and `node --experimental-strip-types conflict.ts`. Both replicas converge; neither knows which price was meant.

If your domain has a defensible rule — lowest price wins, latest approved wins, escalate to a human — the CRDT hands you the raw material and stays out of the way. The 2025 rewrite made this practical at scale: Automerge 3.0 reports that "pasting Moby Dick into an Automerge 2 document consumes 700Mb of memory, in Automerge 3 it only consumes 1.3Mb" — a vendor figure on one document, but an order of magnitude nonetheless — and it is a drop-in, using "the same file format as Automerge 2" with an API that is "nearly fully backwards compatible." Around the core, automerge-repo adds "pluggable networking and storage", the operative word if you intend to own the transport.

Self-Hosting All Three: Slots, Bucket Storage, and an Unsecured Relay

Each has exactly one prerequisite that blocks you on day one. Electric's is in Postgres: "you can use any standard Postgres, version 14 and above" — but "Postgres must have logical replication enabled", and "you also need to connect as a database role that has the REPLICATION attribute." On a managed Postgres you did not provision, that second requirement often ends the evaluation. On Postgres you own, a restart.

bash
#!/usr/bin/env bash
# Electric preflight against the Postgres you intend to sync from.
set -euo pipefail

DB="${DB:-postgresql://postgres@localhost:5432/app}"

# 1. wal_level is restart-only: no in-place switch.
if [ "$(psql "$DB" -Atc 'SHOW wal_level')" != "logical" ]; then
  echo "wal_level is not 'logical' - set it and restart" >&2
  exit 1
fi

# 2. Electric connects as a role carrying REPLICATION.
psql "$DB" <<'SQL'
DO $do$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'electric') THEN
    CREATE ROLE electric LOGIN REPLICATION;
  END IF;
END
$do$;
SQL

# 3. Electric creates a publication and a slot on first start. Both
#    are yours to drop by hand if you ever stop using it.
psql "$DB" -c "SELECT slot_name, active FROM pg_replication_slots \
  WHERE slot_name = 'electric_slot_default';"
Run before the first container start. Step three is the part teams skip: an inactive replication slot retains WAL indefinitely and will eventually fill the volume.
yaml
# Electric sync engine against a Postgres you already own.
services:
  electric:
    image: electricsql/electric:latest
    restart: unless-stopped
    environment:
      # libpq URI; the role must carry REPLICATION.
      DATABASE_URL: postgresql://electric@postgres:5432/app
      # Required unless ELECTRIC_INSECURE=true. Keep out of git.
      ELECTRIC_SECRET: ${ELECTRIC_SECRET:?set this in the environment}
      # Must survive restarts. Defaults to ./persistent.
      ELECTRIC_STORAGE_DIR: /var/lib/electric/persistent
      ELECTRIC_PORT: "3000"
    volumes:
      - electric-data:/var/lib/electric/persistent
    ports:
      # Loopback only: a caching proxy terminates TLS in front.
      - "127.0.0.1:3000:3000"

volumes:
  electric-data:
"The Electric sync engine is an Elixir web service, packaged using Docker" — deployable "anywhere you can run a container with a filesystem and exposed HTTP port". Pin to a digest in production.

PowerSync's day-one surprise is a second database. "The PowerSync Service requires a storage database to store the data and metadata for buckets. You can use either MongoDB or Postgres for this purpose" — separate from your source database. For a Postgres-only estate that once meant adopting MongoDB purely to run the sync layer; no longer, since "available since version 1.3.8 of the powersync-service, you can use Postgres as an alternative bucket storage database."

yaml
# Self-hosted PowerSync Service. Two databases, not one.
replication:
  connections:
    - type: postgresql
      uri: postgresql://powersync@pg-source:5432/app
      sslmode: verify-full

# Postgres bucket storage removes the MongoDB dependency entirely.
storage:
  type: postgresql
  uri: postgresql://powersync@pg-storage:5432/powersync

port: 8080

sync_config:
  content: |
    config:
      edition: 3
    streams:
      todos:
        query: SELECT * FROM todos WHERE owner_id = auth.user_id()
      list_todos:
        auto_subscribe: false
        query: |
          SELECT * FROM todos
          WHERE list_id = subscription.parameter('list_id')

client_auth:
  jwks_uri: https://auth.internal.example.com/.well-known/jwks.json
  audience: ["powersync"]

telemetry:
  disable_telemetry_sharing: true
The docs recommend referencing streams from a separate file so you can edit them without nesting YAML. Nested here so the snippet is one complete document; values can also come from `!env` variables whose names begin with `PS_`.

Automerge's day-one surprise is the bluntest. Its reference relay describes itself: "The server is an unsecured Express app. It is partly for demonstration purposes but it's also a reasonable way to run a public sync server." An accurate division of labour, not a warning label — the project ships the protocol and leaves authentication, TLS and document-level authorisation to the operator. If you already enforce identity at an ingress, small job. If you assumed otherwise, breach.

Security and Residency: What a Sync Engine Moves for You

A sync engine's job description is copying production data onto devices you do not administer. That sentence should reorder your threat model. Three consequences land differently on each engine.

  • Authorisation is a filter definition, not a middleware. Electric scopes a client through the shape it may request, so an over-broad where clause is a data-exposure bug with no runtime symptom. PowerSync scopes through stream queries against auth.user_id(). Both are code-review surface.
  • Residency follows the device, not the datacentre. Under India's DPDP Act and comparable regimes, the question is which personal data left your perimeter, not which server it left from. Shape and stream definitions become compliance evidence.
  • Deletion becomes a distributed problem. Automerge is the sharp case: history is the data structure, so "delete the row" does not mean what it means in Postgres. If you carry erasure obligations, decide which documents may hold personal data before the schema exists.

The secrets picture is mercifully simple. Electric wants one ELECTRIC_SECRET and refuses to start without it unless you opt into ELECTRIC_INSECURE=true — a flag whose only correct production value is the default. PowerSync validates client JWTs against a JWKS endpoint you host. Neither hands a third party a database credential, which is what makes both viable in an air-gapped estate.

Failure Modes That Actually Decide the Choice

Ask how each system fails, then check whether your application survives that failure. For each, finish the sentence "the user sees ____ and we find out by ____." If the second blank is "a customer tells us", you have found the work item.

  1. Electric — the replication slot. The slot is a durable object in your Postgres. If Electric stops consuming and the slot goes inactive, WAL accumulates until the volume fills and the primary stops accepting writes: your sync layer's outage becomes your database's outage. Alarm on pg_replication_slots from day one.
  2. PowerSync — the asynchronous endpoint. Documented, reproducible, and invisible to your observability stack: the client removes its own change when a checkpoint arrives without it. A UI glitch rather than permanent loss, which is precisely why it survives to production.
  3. Automerge — the semantically wrong winner. Convergence is guaranteed; meaning is not. Two offline edits to one field converge on the same value on every replica, and that value can be the one your business rules would have rejected. Never an error, only a number someone disputes three weeks later.

Exit Ramps: Licence, Protocol, and Getting Your Data Back

Price the exit before you commit, as you would price the exit from any infrastructure dependency. Two things set that price: what the licence lets you keep running, and whether the wire protocol is legible without the vendor's client.

Electric is Apache-2.0 and its protocol is HTTP with ordinary cache semantics — a shape log you can read with curl and re-implement if you must. Automerge core is MIT, with a documented sync protocol and a reference relay small enough to replace in an afternoon. PowerSync splits: "PowerSync client SDKs and supporting client-side packages are open-source (Apache 2.0 & MIT) ... The server-side PowerSync Service as well as CLI are available under the Functional Source License (FSL)." The header is FSL-1.1-ALv2, converting to Apache 2.0 "on the second anniversary of the date we make the Software available". FSL carries a competing-use restriction, so it is not unconditionally free despite the announcement calling it "free to use and free to modify" — but every release converts on a known clock.

Then the exit that is no longer hypothetical. Electric Cloud is winding down; Cloud users are told to "self-host or move to another provider", with continuity-of-hosting options available. For a Cloud tenant the exit ramp and the deployment plan are now one document — the compose file above, an owned Postgres, a caching proxy. That the ramp exists is a property of the licence, not the acquisition.

The Long Game

Sync engines are decade-scale commitments. The write boundary shapes the data model, the API, the test suite and the on-call runbook, and none of those unwind cheaply — the discipline that makes repatriating workloads onto owned infrastructure tractable applies here first.

Electric supplies the worked example, six days old at the time of writing. A company changed hands; a managed service is being switched off; the licence on the code did not move. Teams self-hosting the container against their own Postgres read the announcement as news. Teams on the managed plane read it as a migration. Nothing about the software's quality decided which group they were in — the deployment choice did, made months earlier.

That generalises well past these three. Price the steward separately from the code. Prefer the boundary you can operate over the one with the better demo. Then write down which failure mode you accepted, and check in a year whether you still would.

§FAQ/Common questions

Frequently asked

Does ElectricSQL handle writes back to Postgres?

No. Electric's own writes guide states it plainly: Electric does read-path sync, syncing data out of Postgres into local apps and services, and it does not do write-path sync — it provides no built-in solution for getting data back into Postgres. The same guide says you can implement writes in any way you like, using a variety of different patterns. This matters because several currently-indexed comparison posts describe Electric as writing locally and syncing back through a CRDT layer, which is false against the vendor's documentation. If you adopt Electric you are also adopting an API you design, whose transactionality, authorisation and conflict rules are yours to test.

What happened to Electric Cloud after the Databricks announcement?

On 11 August 2026 Electric announced it is joining Databricks, adding its data primitives and reactivity to Lakebase, and said that moving forward Electric will be building with Neon inside Databricks. The same post states that Electric Cloud is winding down and that Cloud users will need to self-host or move to another provider, while also pointing to options for continuity of hosting with professional support. Checked on 17 August 2026, the Cloud marketing page was still live and still carrying a signup call-to-action, and the deployment guide still described Cloud as the simplest way to use Electric — the documentation had not caught up. Do not plan a new deployment around the managed service; the Apache-2.0 container against your own Postgres is the path that is not affected.

Why does PowerSync require a synchronous write endpoint?

Because the client trusts checkpoints. PowerSync issues write checkpoints so clients confirm their own mutations have synced back before applying downloaded data locally. If your endpoint queues the write and returns before the source database is updated, the next checkpoint is computed from a database that does not contain that mutation, and the documentation says those changes will be removed from the client — manifesting as UI glitches where the change disappears from the device for a few seconds and then re-appears once a later checkpoint contains it. It is a UI-glitch class of bug rather than silent permanent loss, but it is invisible in server-side telemetry, so it tends to survive to production.

Can Automerge resolve conflicts correctly on its own?

It resolves them deterministically, which is not the same thing. Automerge's documentation says the only case it cannot handle automatically, because there is no well-defined resolution, is when users concurrently update the same property in the same object. In that case it picks one of the concurrently written values as the winner and ensures the winner is the same on all nodes. Every replica converges; nothing guarantees they converge on the value your business rules would have chosen. The losing values are retained and readable via Automerge.getConflicts(), so applying a domain rule — lowest price wins, latest approved wins, escalate to a human — is your application's job, not the CRDT's.

Can I self-host PowerSync without introducing MongoDB?

Yes, since powersync-service 1.3.8. The PowerSync Service requires a storage database for bucket data and metadata, separate from your source database, and the documentation states you can use either MongoDB or Postgres for this purpose — with Postgres available as an alternative bucket storage backend from version 1.3.8 onward. For a Postgres-only estate that removes the MongoDB dependency entirely, leaving you with the source database, a storage database, and the service container. Note the licence split while you are planning: the client SDKs are Apache 2.0 and MIT, while the server-side service and CLI are under the Functional Source License, FSL-1.1-ALv2, which converts to Apache 2.0 on the second anniversary of each release's availability.

electricsqllocal-first sync enginepowersyncautomerge crdtself-hosted sync engineoffline-first architecture

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.