Skip to content

Fix: PowerSync Not Working — Offline Sync Failing, Queries Returning Stale Data, or Backend Connection Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix PowerSync issues — SQLite local database, sync rules configuration, backend connector setup, watched queries, offline-first patterns, and React and React Native integration.

The Problem

PowerSync connects but local queries return empty:

const result = await db.getAll('SELECT * FROM todos');
// [] — empty even though the backend database has data

Or data changes locally but doesn’t sync to the server:

await db.execute('INSERT INTO todos (id, title) VALUES (?, ?)', [uuid(), 'New task']);
// Inserts locally but never appears on the server

Or the sync status stays “connecting” indefinitely:

PowerSync: connecting...
PowerSync: connecting...
// Never reaches "connected"

Why This Happens

PowerSync provides offline-first sync between a local SQLite database and a remote backend (Postgres, Supabase, MongoDB, etc.):

  • Sync rules define what data syncs — PowerSync uses server-defined “sync rules” that control which rows sync to which users. If sync rules don’t match the data or the user’s parameters, no data arrives at the client.
  • Writes go through a backend connector — PowerSync’s local SQLite is read-only from the sync perspective. Local writes are queued in an “upload queue” and sent to the server through your custom backend connector. Without a connector implementation, writes stay queued forever.
  • The local database schema must match sync rules — the client defines its own SQLite schema that must align with what the sync rules send. Mismatched column names or types cause data to be dropped during sync.
  • Authentication must be set up — PowerSync Cloud authenticates clients using JWTs. Without valid credentials, the sync connection fails.

A second class of failure is timing. PowerSync’s sync engine starts in the background after db.connect(connector) is called. Watched queries return whatever is in local SQLite at the moment they run. If the user is brand new and you query immediately after connect, the result is empty — not because sync failed, but because the first sync window has not completed. Use useStatus() (or db.currentStatus) to wait for hasSynced === true before treating an empty result as “no data”.

A third class is the local schema. PowerSync infers the SQLite columns from the Schema you pass to the constructor. Columns missing from the schema do not exist on the client even if the backend sends them. The sync engine silently drops unknown columns. This is the most common reason “data syncs down but my new field is missing” — the backend added a column but the client Schema was not updated.

Version History (PowerSync 1.0 GA, React/React Native SDKs, and ElectricSQL comparison)

Offline-first sync libraries went through several false starts before PowerSync and ElectricSQL became practical defaults.

  • CouchDB/PouchDB era (2010s) — PouchDB was the original “sync your database to the browser” tool but assumed an eventually-consistent document model that did not map cleanly to relational backends. Many teams adopted it then abandoned it when SQL came back into fashion.
  • RxDB and WatermelonDB (late 2010s) — improved on PouchDB by adding reactive queries and better React Native support. Still document-oriented or partially relational.
  • ElectricSQL alpha and beta (2022-2023) — first open-source attempt at “Postgres on the server, SQLite on the device, with CRDT-style conflict resolution”. Innovative but rough around the edges, and the schema migration story remained painful.
  • PowerSync open beta (2023) — Journey Apps’ product, originally an internal tool for industrial mobile apps, opened to public beta. Different from ElectricSQL: PowerSync uses a write queue + backend connector pattern rather than CRDTs, which keeps the server as the source of truth.
  • PowerSync 1.0 GA (April 2024) — first stable release. Web SDK (@powersync/web) and React Native SDK (@powersync/react-native) shipped together, both with the same TypeScript API. JavaScript SDK uses sql.js + IndexedDB on the web and react-native-quick-sqlite on React Native.
  • Backend connector ecosystem (2024-2026) — first-class Supabase integration, Postgres integration via PowerSync Cloud, and MongoDB integration. The connector pattern means you can use any backend, but pre-built ones save weeks of work.
  • Hybrid Postgres + SQLite sync (2024→) — PowerSync Cloud streams Postgres logical replication into per-user buckets, so the server side is operationally similar to a CDC pipeline. ElectricSQL took a different architectural direction (Electric proxy in front of Postgres), and the two products have diverged.
  • ElectricSQL comparison (2025) — ElectricSQL pivoted toward “Shapes” (server-defined view streams) while PowerSync continued with its bucket model. Pick PowerSync if you want a server-as-source-of-truth, queueable-writes model. Pick ElectricSQL if you want SQL views streamed directly to the client.

Practical implication: most “PowerSync not working” issues in 2026 trace back to either sync rules (server side) or the local Schema (client side). The SDKs themselves have been stable since 1.0; the moving parts are configuration. If you are evaluating offline-first sync, also consider ElectricSQL for read-heavy workloads and RxDB for offline-first apps with simpler conflict requirements.

Fix 1: Set Up PowerSync with React

npm install @powersync/react @powersync/web
// lib/powersync/schema.ts — local SQLite schema
import { column, Schema, Table } from '@powersync/web';

const todos = new Table({
  title: column.text,
  completed: column.integer,  // SQLite doesn't have boolean — use 0/1
  user_id: column.text,
  created_at: column.text,
});

const projects = new Table({
  name: column.text,
  description: column.text,
  owner_id: column.text,
});

export const schema = new Schema({ todos, projects });

// TypeScript types (optional but recommended)
export type Todo = {
  id: string;
  title: string;
  completed: number;
  user_id: string;
  created_at: string;
};

export type Project = {
  id: string;
  name: string;
  description: string;
  owner_id: string;
};
// lib/powersync/connector.ts — backend connector
import { AbstractPowerSyncDatabase, PowerSyncBackendConnector, UpdateType } from '@powersync/web';

export class BackendConnector implements PowerSyncBackendConnector {
  constructor(private apiUrl: string) {}

  // Fetch credentials for PowerSync Cloud
  async fetchCredentials() {
    const res = await fetch(`${this.apiUrl}/api/powersync/token`);
    const data = await res.json();

    return {
      endpoint: data.powersyncUrl,  // PowerSync Cloud endpoint
      token: data.token,             // JWT token
    };
  }

  // Upload local changes to your backend
  async uploadData(database: AbstractPowerSyncDatabase) {
    const transaction = await database.getNextCrudTransaction();
    if (!transaction) return;

    try {
      for (const op of transaction.crud) {
        switch (op.op) {
          case UpdateType.PUT:
            await fetch(`${this.apiUrl}/api/${op.table}`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ id: op.id, ...op.opData }),
            });
            break;

          case UpdateType.PATCH:
            await fetch(`${this.apiUrl}/api/${op.table}/${op.id}`, {
              method: 'PATCH',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(op.opData),
            });
            break;

          case UpdateType.DELETE:
            await fetch(`${this.apiUrl}/api/${op.table}/${op.id}`, {
              method: 'DELETE',
            });
            break;
        }
      }

      await transaction.complete();
    } catch (error) {
      console.error('Upload failed:', error);
      // Transaction will be retried on next sync
    }
  }
}
// lib/powersync/db.ts — initialize PowerSync
import { PowerSyncDatabase } from '@powersync/web';
import { schema } from './schema';
import { BackendConnector } from './connector';

let powerSyncInstance: PowerSyncDatabase | null = null;

export async function initPowerSync() {
  if (powerSyncInstance) return powerSyncInstance;

  const db = new PowerSyncDatabase({
    schema,
    database: { dbFilename: 'myapp.db' },
  });

  const connector = new BackendConnector(
    process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
  );

  await db.init();
  await db.connect(connector);

  powerSyncInstance = db;
  return db;
}

Fix 2: React Provider and Hooks

// app/providers.tsx
'use client';

import { PowerSyncContext } from '@powersync/react';
import { useEffect, useState } from 'react';
import { initPowerSync } from '@/lib/powersync/db';
import type { PowerSyncDatabase } from '@powersync/web';

export function PowerSyncProvider({ children }: { children: React.ReactNode }) {
  const [db, setDb] = useState<PowerSyncDatabase | null>(null);

  useEffect(() => {
    initPowerSync().then(setDb);
  }, []);

  if (!db) return <div>Initializing database...</div>;

  return (
    <PowerSyncContext.Provider value={db}>
      {children}
    </PowerSyncContext.Provider>
  );
}

// app/layout.tsx
import { PowerSyncProvider } from './providers';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <PowerSyncProvider>{children}</PowerSyncProvider>
      </body>
    </html>
  );
}
// components/TodoList.tsx — reactive queries
'use client';

import { useQuery, useStatus } from '@powersync/react';
import type { Todo } from '@/lib/powersync/schema';

function TodoList({ userId }: { userId: string }) {
  // Watched query — re-renders when data changes
  const { data: todos, isLoading } = useQuery<Todo>(
    'SELECT * FROM todos WHERE user_id = ? ORDER BY created_at DESC',
    [userId],
  );

  const status = useStatus();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {/* Sync status indicator */}
      <div>
        {status.connected ? 'Online' : 'Offline'}
        {status.uploading && ' (uploading...)'}
        {status.downloading && ' (downloading...)'}
      </div>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed === 1}
              onChange={() => toggleTodo(todo.id, todo.completed)}
            />
            {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

Fix 3: Local Writes (Offline-First)

// hooks/useTodos.ts
'use client';

import { usePowerSync, useQuery } from '@powersync/react';
import type { Todo } from '@/lib/powersync/schema';
import { v4 as uuid } from 'uuid';

export function useTodos(userId: string) {
  const db = usePowerSync();
  const { data: todos } = useQuery<Todo>(
    'SELECT * FROM todos WHERE user_id = ? ORDER BY created_at DESC',
    [userId],
  );

  async function addTodo(title: string) {
    // Write to local SQLite — instantly available
    await db.execute(
      'INSERT INTO todos (id, title, completed, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
      [uuid(), title, 0, userId, new Date().toISOString()],
    );
    // PowerSync queues this write and uploads it via the connector
  }

  async function toggleTodo(id: string, currentCompleted: number) {
    await db.execute(
      'UPDATE todos SET completed = ? WHERE id = ?',
      [currentCompleted === 1 ? 0 : 1, id],
    );
  }

  async function deleteTodo(id: string) {
    await db.execute('DELETE FROM todos WHERE id = ?', [id]);
  }

  return { todos, addTodo, toggleTodo, deleteTodo };
}

// Usage in component
function TodoApp({ userId }: { userId: string }) {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodos(userId);

  return (
    <div>
      <button onClick={() => addTodo('New task')}>Add Todo</button>
      {todos.map(todo => (
        <div key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed === 1}
            onChange={() => toggleTodo(todo.id, todo.completed)}
          />
          {todo.title}
          <button onClick={() => deleteTodo(todo.id)}>Delete</button>
        </div>
      ))}
    </div>
  );
}

Fix 4: Sync Rules (Server-Side)

Sync rules define which data syncs to which users:

# sync-rules.yaml — PowerSync Cloud configuration
bucket_definitions:
  # User's own data
  user_data:
    parameters: SELECT token_parameters.user_id AS user_id
    data:
      - SELECT * FROM todos WHERE user_id = bucket.user_id
      - SELECT * FROM projects WHERE owner_id = bucket.user_id

  # Shared project data
  project_members:
    parameters:
      SELECT project_id FROM project_members
      WHERE user_id = token_parameters.user_id
    data:
      - SELECT * FROM projects WHERE id = bucket.project_id
      - SELECT * FROM todos WHERE project_id = bucket.project_id

Fix 5: Token Endpoint (Authentication)

// app/api/powersync/token/route.ts
import jwt from 'jsonwebtoken';
import { auth } from '@/auth';

export async function GET() {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Create a JWT for PowerSync with user-specific parameters
  const token = jwt.sign(
    {
      sub: session.user.id,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 300,  // 5 minutes
      parameters: {
        user_id: session.user.id,
      },
    },
    process.env.POWERSYNC_PRIVATE_KEY!,
    { algorithm: 'RS256' },
  );

  return Response.json({
    token,
    powersyncUrl: process.env.NEXT_PUBLIC_POWERSYNC_URL!,
  });
}

Fix 6: Conflict Resolution

// Backend connector — handle conflicts during upload
async uploadData(database: AbstractPowerSyncDatabase) {
  const transaction = await database.getNextCrudTransaction();
  if (!transaction) return;

  try {
    for (const op of transaction.crud) {
      if (op.op === UpdateType.PATCH) {
        // Send with last known server version for conflict detection
        const res = await fetch(`${this.apiUrl}/api/${op.table}/${op.id}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            ...op.opData,
            _version: op.metadata?.version,  // For optimistic concurrency
          }),
        });

        if (res.status === 409) {
          // Conflict — server has a newer version
          // PowerSync will re-sync the latest server data
          console.warn('Conflict detected for', op.table, op.id);
          continue;  // Skip this op, let sync resolve it
        }
      }
    }
    await transaction.complete();
  } catch (error) {
    // Will retry on next sync cycle
  }
}

Still Not Working?

Queries return empty after connecting — sync rules might not match the user’s token parameters. Check that token_parameters.user_id in sync rules matches the parameters.user_id in the JWT. Also verify the table names in sync rules match the Postgres tables exactly.

Writes queue but never upload — the uploadData method in your connector is either not implemented, throwing errors silently, or not calling transaction.complete(). Add logging to the upload method. Also check that the backend API endpoints your connector calls are accessible and returning success responses.

Sync status stays “connecting” — the PowerSync Cloud endpoint URL or token is invalid. Check that NEXT_PUBLIC_POWERSYNC_URL is set correctly. Verify the JWT token endpoint returns valid tokens. Enable debug logging to see connection errors.

Data syncs down but local writes are lost on page reload — PowerSync persists the local SQLite database using IndexedDB (web) or the filesystem (React Native). If the browser clears IndexedDB, local data is lost. Ensure writes are uploaded before the user navigates away by checking status.uploading.

A new column appears in Postgres but never on the client — the client Schema does not include it. PowerSync drops unknown columns silently during sync. Add the column to your Schema in lib/powersync/schema.ts, then bump your local DB version so the migration runs. Without a version bump the existing local SQLite file keeps its old schema.

Empty result on first load even though the user has data — sync has not finished yet. Wait for useStatus().hasSynced === true before deciding to render an empty state. Initial sync can take several seconds on a fresh device or large bucket.

JWT token works in dev but is rejected in production — PowerSync Cloud validates the JWT against the public key configured for your project. If the production project uses a different RSA keypair than dev, the same token signing logic fails. Verify that POWERSYNC_PRIVATE_KEY matches the public key uploaded to the production PowerSync project.

For related offline-first and database issues, see Fix: ElectricSQL Not Working and Fix: Turso Not Working. For SQLite locking and IndexedDB issues that often surface through PowerSync’s storage layer, see Fix: SQLite Database Is Locked and Fix: IndexedDB Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles