Back to Blog
ElectricSQL: When Your Database Needs to Live on the Edge, Offline-First
8 min readAug 14, 20260 views

ElectricSQL: When Your Database Needs to Live on the Edge, Offline-First

Building a truly offline-first application that feels instant, even with complex data, is a monumental task. ElectricSQL promises to make this a reality by extending your Postgres database directly to the client, keeping everything in sync and highly available.

DatabaseFull-StackDistributed SystemsFrontendTypeScript
Share

by Sunil Band

The Offline-First Dream and Its Nightmares

We've all built applications where the network is assumed to be reliable. Fetch data, display data, send updates. It's a simple mental model. But in the real world, networks are flaky. Users expect apps to feel instant, even with poor connectivity, and they certainly expect to be productive when completely offline. Achieving this, especially with complex data and real-time collaboration, is a nightmare of state management, conflict resolution, and synchronization logic.

Traditionally, you'd roll your own solution. Cache data in local storage, manage a queue of outgoing changes, implement optimistic UI, and then pray your conflict resolution logic holds up when the user comes back online. It's a massive undertaking, prone to subtle bugs that only manifest in the wild. This is where ElectricSQL comes in. It's not just a library; it's a distributed database system that extends your Postgres database to the client, making offline-first a first-class citizen rather than an afterthought.

ElectricSQL gives you a local, embedded SQLite database on the client that's kept in eventual consistency with your upstream Postgres. This means your frontend can query and update data instantly against a local replica, eliminating network latency for reads and writes. When connectivity returns, changes are synced bi-directionally, and conflicts are handled. It's a game-changer for building truly resilient, high-performance applications that work anywhere.

How ElectricSQL Rewrites the Data Flow

At its core, ElectricSQL operates by mirroring a subset of your Postgres database to an embedded SQLite database in your client application. This isn't just a cache; it's a fully queryable, transactional database. It injects a proxy between your application code and Postgres, capturing all changes and distributing them to connected clients. On the client, it provides a client-side library that gives you real-time access to the local SQLite, along with hooks for React, Vue, and others.

The real magic is in the replication and synchronization. ElectricSQL uses a logical replication approach, capturing changes from Postgres's Write-Ahead Log (WAL) and sending them to clients. On the client, your app interacts with SQLite. When you make a local change, it's immediately committed to SQLite and then queued for replication back to Postgres. This architecture means your UI always has data available locally, and updates feel instant because they don't wait for a network roundtrip.

Let's look at how you'd set this up in a typical React application. First, you'd need to install the ElectricSQL client library and the React integration.

bash
npm install electric-sql @electric-sql/react

Then, you'd initialize ElectricSQL in your application. This involves setting up the client, connecting to the sync service, and providing a schema. ElectricSQL generates client-side types based on your Postgres schema, so you get full type safety out of the box, which is critical for complex data interactions.

typescript
// src/electric.ts
import { ElectricClient, electrify } from 'electric-sql/client';
import { schema } from './generated/client'; // Generated from your Postgres schema

export type Database = ElectricClient<typeof schema>;

const config = {
  url: 'ws://localhost:5133', // Your ElectricSQL sync service URL
};

let electric: Database | null = null;

export const initElectric = async () => {
  if (electric) {
    return electric;
  }
  const { db } = await electrify(config, schema);
  electric = db as Database;
  return electric;
};

Now, in your React component, you can use the useLiveQuery hook to fetch data. This hook automatically re-renders your component when the local SQLite database changes, whether from a local mutation or a remote sync.

typescript
// src/components/TodoList.tsx
import React from 'react';
import { useLiveQuery } from '@electric-sql/react';
import { initElectric, Database } from '../electric';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoListProps {
  electric: Database;
}

const TodoList: React.FC<TodoListProps> = ({ electric }) => {
  // Subscribe to live changes in the 'todos' table
  const { results } = useLiveQuery(electric.db.todos.liveMany());

  const todos = (results || []).map(r => r as Todo);

  const toggleTodo = async (id: string, completed: boolean) => {
    await electric.db.todos.update({
      where: { id },
      data: { completed: !completed },
    });
  };

  const addTodo = async (text: string) => {
    await electric.db.todos.create({ data: { id: Date.now().toString(), text, completed: false } });
  };

  if (!todos) {
    return <div>Loading todos...</div>;
  }

  return (
    <div>
      <input
        type="text"
        onKeyDown={async (e) => {
          if (e.key === 'Enter' && e.currentTarget.value.trim() !== '') {
            await addTodo(e.currentTarget.value.trim());
            e.currentTarget.value = '';
          }
        }}
        placeholder="Add a new todo"
      />
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id, todo.completed)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

Notice how there are no explicit fetch calls or useEffect hooks for data synchronization. All interactions are directly with electric.db.todos, which transparently handles local storage and syncing with Postgres. This drastically simplifies your data layer, reducing boilerplate and potential for bugs. The liveMany() call sets up a reactive query that updates whenever the underlying data changes, local or remote.

Conflict Resolution: The Elephant in the Room

No distributed system is complete without a strategy for conflict resolution. What happens when two users, or the same user offline and then online, modify the same piece of data differently? ElectricSQL offers configurable strategies.

By default, ElectricSQL uses a Last Write Wins (LWW) strategy. The most recent change, based on a timestamp, is the one that's kept. This is often sufficient for many applications, especially those where concurrent edits are rare or where the user expects their most recent action to take precedence. However, for more complex scenarios, like collaborative document editing, LWW might not be enough.

For those cases, ElectricSQL provides mechanisms to implement custom conflict resolution logic. You can hook into the synchronization process and define how to merge conflicting changes based on your application's domain rules. This might involve applying specific business logic or even prompting the user to resolve a conflict. It's a powerful escape hatch for when default strategies don't fit.

Consider a scenario where two users update the same text field of a document concurrently. With LWW, one change silently overwrites the other. If this is undesirable, you could implement a custom resolver that, for example, concatenates the changes or flags the conflict for manual review. This flexibility is crucial for applications with high concurrency requirements.

typescript
// Example: Custom conflict resolution (conceptual, actual implementation details vary)
import { ElectricClient } from 'electric-sql/client';

async function setupCustomConflictResolution(electric: ElectricClient<any>) {
  electric.db.setConflictHandler(
    'documents', // Table name
    async ({ local, remote, common }) => {
      // local: the version of the record on the client
      // remote: the version of the record from the server/other client
      // common: the common ancestor of local and remote

      if (local.text !== common.text && remote.text !== common.text) {
        // Both local and remote changed the text field
        console.warn(`Conflict detected for document ${local.id}. Local: '${local.text}', Remote: '${remote.text}'`);
        // Example: Combine them, or choose remote, or revert to common
        return { ...local, text: `(Local: ${local.text} | Remote: ${remote.text})` };
      }
      // Default to Last Write Wins for other fields or if only one side changed
      return remote;
    }
  );
}

This level of control allows you to tailor the data consistency model precisely to your application's needs, moving beyond simple LWW when necessary. It acknowledges that not all data is created equal, and some conflicts require more nuanced handling.

The Trade-offs and Considerations

ElectricSQL is a powerful tool, but it's not a silver bullet. There are trade-offs to consider before diving in. First, it introduces a new layer of infrastructure. You'll need to run the ElectricSQL sync service alongside your Postgres database. While it simplifies client-side code, it adds to your operational overhead.

Second, the client-side SQLite database means your client bundles will be larger. You're embedding a fully functional database, which comes with a size cost. For extremely lean applications or those with very simple data requirements, this might be overkill. However, for anything with significant data interaction, the benefits often outweigh the bundle size increase.

Third, while ElectricSQL handles much of the complexity, reasoning about eventual consistency and distributed state can still be challenging. Understanding the various states a piece of data can be in (local-only, synced, conflicting) is crucial for debugging and building robust features. Their documentation is good, but it's a new mental model for many.

Finally, the current version is heavily tied to Postgres. If your backend database isn't Postgres, you'll need to adapt or consider alternative solutions. This isn't necessarily a limitation, as Postgres is a phenomenal database, but it's a constraint to be aware of.

Despite these points, for applications that genuinely need offline resilience, real-time responsiveness, and multi-user synchronization, ElectricSQL offers a significantly better developer experience than building these capabilities from scratch. The productivity gains from not having to write complex caching and synchronization logic manually are immense.

Wrapping up

ElectricSQL fundamentally changes how you think about data in client-side applications. It shifts the paradigm from a reactive, network-dependent fetch model to a proactive, local-first database interaction. This isn't just about speed; it's about building applications that are inherently more robust and user-friendly in an imperfect world.

If you're building an application where connectivity is unreliable, or users expect instant interactions regardless of network conditions, you owe it to yourself to explore ElectricSQL. Start by cloning their Quickstart repository and experimenting with their todo-electron or todo-react examples. See how quickly you can get a truly offline-first experience running without writing a single line of explicit synchronization code.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕