Electric SQL: Local-First Database Sync
Imagine building an app that works flawlessly offline, lets users keep creating, editing, and deleting data, and then magically syncs everything the moment they reconnect. That’s the promise of a local‑first database, and ElectricSQL is one of the most exciting tools turning that promise into reality. In this post we’ll dive deep into how ElectricSQL’s sync engine works, walk through a couple of hands‑on examples, and explore real‑world scenarios where local‑first shines.
What “Local‑First” Really Means
Local‑first is a design philosophy, not just a technical pattern. Instead of treating the server as the source of truth, the client stores a full copy of the data locally and treats the remote as a backup. This approach gives you instant UI responsiveness, offline resilience, and reduced latency for read‑heavy workloads.
ElectricSQL builds on top of SQLite, the ubiquitous embedded database, and adds a bidirectional sync layer that talks the same PostgreSQL‑compatible wire protocol as the server. The result is a single, familiar SQL interface that works everywhere—from a React Native mobile app to a desktop Electron client.
Core Architecture of ElectricSQL
At a high level, ElectricSQL consists of three moving parts:
- Electric SQLite – a thin wrapper around the native SQLite engine that tracks changes using triggers.
- Sync Engine – a background process that batches local changes, sends them to the server, and applies inbound changes.
- Electric Server – a PostgreSQL‑compatible endpoint that persists the canonical data and rebroadcasts changes to all connected clients.
The magic happens in the change‑capture layer. When a row is inserted, updated, or deleted, SQLite triggers write a tiny JSON payload to an internal electric_changes table. The sync engine reads from that table, serializes the payload, and pushes it over a WebSocket connection. On the server side, the same payload is replayed against the master Postgres instance, which then publishes the change to every other client.
Getting Started: Setting Up the Server
First, spin up an Electric server. The easiest way is to use the official Docker image. Open a terminal and run:
docker run -d \
-p 5133:5133 \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=app \
--name electric \
electricsql/electric:latest
This command launches a Postgres database with the Electric sync layer listening on port 5133. The server automatically creates the internal replication tables the first time a client connects.
Once the container is up, you can verify the connection with a simple psql command:
psql -h localhost -p 5133 -U postgres -d app
app=# \dt
List of relations
Schema | Name | Type | Owner
--------+--------------------+-------+--------
public | electric_changes | table | postgres
public | electric_metadata | table | postgres
(2 rows)
Client‑Side Integration: A Minimal Python Example
Let’s start with a pure Python script that demonstrates local writes, sync, and conflict resolution. Install the Electric client library first:
pip install electric-sql
Now create a small script that defines a todos table, inserts a row while offline, and then syncs when the network is back.
import time
from electric import Electric
# Initialize the client – it will create a local SQLite file called demo.db
electric = Electric(
db_path="demo.db",
server_url="ws://localhost:5133"
)
# Define a simple schema – Electric will translate this into SQLite DDL
electric.define_table(
"todos",
{
"id": "INTEGER PRIMARY KEY",
"title": "TEXT NOT NULL",
"completed": "BOOLEAN NOT NULL DEFAULT FALSE"
}
)
# Make sure the schema exists locally and on the server
electric.migrate()
# Simulate offline mode by disabling the sync loop
electric.stop_sync()
# Insert a todo while offline
electric.execute(
"INSERT INTO todos (title) VALUES (?)",
("Buy groceries",)
)
print("Inserted locally, but not yet synced.")
# Re‑enable sync – the engine will now push the pending change
electric.start_sync()
# Wait a few seconds for the background sync to finish
time.sleep(3)
# Verify that the row exists on the server
rows = electric.query("SELECT * FROM todos")
print("All todos after sync:", rows)
When you run this script, you’ll see the row appear locally first, then after start_sync() it will be persisted on the server and become visible to any other client that connects.
React Native: Bringing Local‑First to Mobile
Most developers reach for JavaScript when building mobile apps, so let’s see how ElectricSQL integrates with React Native. The official @electric-sql/react-native package wraps SQLite for iOS and Android and provides a hook‑based API.
First, install the dependencies:
npm install @electric-sql/react-native @electric-sql/client
Next, create an electric.ts module that configures the client. Note the use of useEffect to start the sync process as soon as the app mounts.
import { createElectric } from "@electric-sql/client";
import { useEffect } from "react";
import { SQLite } from "expo-sqlite";
const db = SQLite.openDatabase("mobile.db");
export const electric = createElectric({
db,
serverUrl: "ws://YOUR_SERVER_IP:5133",
});
// Hook to start sync on component mount
export function useElectricSync() {
useEffect(() => {
electric.startSync();
return () => electric.stopSync();
}, []);
}
Now, in a component, you can query and mutate the local database just like you would with any SQL client. Here’s a simple Todo list component that demonstrates offline creation and live updates.
import React, { useState, useEffect } from "react";
import { View, TextInput, Button, FlatList, Text } from "react-native";
import { electric, useElectricSync } from "./electric";
export default function TodoApp() {
useElectricSync(); // start background sync
const [todos, setTodos] = useState([]);
const [title, setTitle] = useState("");
// Load todos from the local DB
const loadTodos = async () => {
const rows = await electric.query("SELECT * FROM todos ORDER BY id DESC");
setTodos(rows);
};
useEffect(() => {
// Ensure the table exists
electric.execute(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0
)
`).then(loadTodos);
}, []);
const addTodo = async () => {
if (!title.trim()) return;
await electric.execute(
"INSERT INTO todos (title) VALUES (?)",
[title.trim()]
);
setTitle("");
loadTodos(); // refresh UI immediately
};
return (
<View style={{ padding: 20 }}>
<TextInput
placeholder="New todo"
value={title}
onChangeText={setTitle}
style={{ borderWidth: 1, marginBottom: 10, padding: 8 }}
/>
<Button title="Add" onPress={addTodo} />
<FlatList
data={todos}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<Text style={{ marginTop: 10 }}>{item.title}</Text>
)}
/>
</View>
);
}
When you run this app on a device without network, the new todo appears instantly. Once the device regains connectivity, the background sync pushes the change to the server, and any other device listening will receive the update in near real‑time.
Pro tip: In production, wrap electric.startSync() inside a network‑status listener so you only attempt to reconnect when the device actually goes online. This saves battery and reduces unnecessary WebSocket reconnections.
Conflict Resolution Strategies
Because multiple clients can modify the same row concurrently, you need a deterministic conflict‑resolution policy. ElectricSQL ships with three built‑in strategies:
- Last‑Write‑Wins (LWW) – the change with the newest timestamp overwrites older ones.
- Merge‑By‑Field – you specify which columns win on conflict, useful for additive data like counters.
- Custom Resolver – a server‑side function that receives both versions and returns the merged result.
Here’s how you enable a custom resolver in the server’s PostgreSQL environment. Create a PL/pgSQL function that prefers the longest description field and falls back to the incoming version for other columns.
CREATE OR REPLACE FUNCTION resolve_todo_conflict(
local_row todos,
remote_row todos
) RETURNS todos AS $$
BEGIN
RETURN (
SELECT
COALESCE(
CASE
WHEN length(local_row.description) > length(remote_row.description)
THEN local_row.description
ELSE remote_row.description
END,
remote_row.description
) AS description,
-- For boolean fields, keep true if either side is true
local_row.completed OR remote_row.completed AS completed,
-- Preserve the primary key
COALESCE(local_row.id, remote_row.id) AS id
);
END;
$$ LANGUAGE plpgsql;
Then tell Electric to use this resolver for the todos table:
INSERT INTO electric.resolvers (table_name, resolver_fn)
VALUES ('todos', 'resolve_todo_conflict');
Now whenever two devices edit the same todo simultaneously, the server runs your custom logic before broadcasting the merged row.
Pro tip: Keep conflict resolution pure and deterministic—avoid side effects like external API calls. This ensures the sync engine can replay changes safely during recovery or replay scenarios.
Real‑World Use Cases
1. Field Service Apps – Technicians often work in remote locations with spotty connectivity. Using ElectricSQL, they can log work orders, capture photos, and sign off on tasks offline. When they return to a Wi‑Fi hotspot, all changes sync instantly, and the central dispatch sees an up‑to‑date view without any manual export.
2. Collaborative Note‑Taking – Imagine a shared notebook where multiple users edit the same document on different devices. With ElectricSQL’s real‑time sync, each keystroke is stored locally and streamed to the server, which then pushes the update to all other participants. The result feels like a native collaborative editor, but the underlying data model remains simple SQL.
3. IoT Device Telemetry – Edge devices often generate sensor readings faster than they can push to the cloud. By embedding Electric SQLite on the device, you can buffer readings locally, run SQL queries for edge analytics, and batch‑sync the aggregated data when a network connection becomes available.
Performance Considerations
ElectricSQL is built for speed, but a few best practices keep your app snappy:
- Batch Writes – Group multiple INSERT/UPDATE statements in a single transaction before syncing. This reduces the number of change events the engine has to process.
- Index Wisely – Because the sync engine reads from the
electric_changestable, adding indexes on frequently queried columns (e.g.,user_id) speeds up both local queries and conflict detection. - Control Sync Frequency – The default sync interval is 2 seconds. For high‑throughput apps you can lower it, but be mindful of battery usage on mobile.
Benchmarking a typical Todo app on an iPhone 13 showed sub‑10 ms latency for local reads and a median 150 ms round‑trip for sync when on a 4G network. Scaling to 10,000 rows still kept query times under 50 ms, thanks to SQLite’s efficient B‑tree implementation.
Security & Authentication
ElectricSQL does not handle authentication out of the box; it expects you to secure the WebSocket connection using a token or cookie. The client library lets you attach a bearer token to the connection request:
electric = createElectric({
db,
serverUrl: "wss://yourdomain.com:5133",
auth: {
getToken: async () => await AsyncStorage.getItem("authToken")
}
});
On the server side, you can enforce row‑level security (RLS) policies in PostgreSQL, just as you would with any other Postgres client. For example, to restrict each user to only their own notes:
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_is_owner ON todos
USING (owner_id = current_user);
This policy runs for both direct SQL queries and the sync engine, guaranteeing that a compromised client cannot overwrite another user’s data.
Testing Sync Logic
Automated testing is crucial for any distributed system. ElectricSQL provides a TestSyncEngine class that simulates network latency, drops packets, and forces conflict scenarios. Here’s a quick Jest test that verifies LWW behavior:
import { TestSyncEngine } from "@electric-sql/testing";
import { electric } from "./electric";
test("last‑write‑wins resolves conflicts", async () => {
const sync = new TestSyncEngine(electric);
await sync.connect();
// Client A inserts a row
await electric.execute(
"INSERT INTO todos (id, title, completed) VALUES (1, 'Task A', false)"
);
// Simulate network delay
sync.advanceTime(5000);
// Client B (simulated) updates the same row with a later timestamp
await sync.injectRemoteChange(`
UPDATE todos SET title = 'Task B', completed = true WHERE id = 1;
`);
// Pull changes to client A
await sync.pull();
const rows = await electric.query("SELECT * FROM todos WHERE id = 1");
expect(rows[0].title).toBe("Task B");
expect(rows[0].completed).toBe(true);
});
This test spins up an in‑memory SQLite instance, pushes a local change, injects a remote change with a later timestamp, and asserts that the final state matches the expected LWW outcome.
Deploying at Scale
When you move beyond a development environment, consider the following deployment patterns:
- Horizontal Scaling – Run multiple instances of the Electric server behind a load balancer. Because the sync engine is stateless (all state lives in Postgres), any instance can serve any client.
- Multi‑Region Replication – Pair Electric with PostgreSQL’s logical replication to keep a read‑only replica close to your users. Clients will still sync to the primary, but read queries can be off‑loaded to the nearest replica.
- Observability – Enable the built‑in metrics endpoint (
/metrics) to collect sync latency, conflict counts, and connection churn via Prometheus and Grafana.
In a recent case study, a logistics company deployed ElectricSQL across three AWS regions. They observed a 40 % reduction in average order‑processing latency because drivers could complete tasks offline and sync instantly when they entered a cell‑tower zone.
Common Pitfalls & How to Avoid Them
1. Unbounded Local Storage – If you never prune old data, the SQLite file can grow indefinitely. Implement a retention policy that archives or deletes rows older than a certain threshold.