skills/powersync/references/sdks/powersync-js.md

35 KiB

name description metadata
powersync-js PowerSync JavaScript/TypeScript SDK — schema, backend connector, queries, transactions, sync status, and debugging
tags
javascript, typescript, web, sqlite, offline-first

Load this when working on any JavaScript or TypeScript project with PowerSync. This is the foundation file — always load it first, then load the applicable framework-specific file alongside it.

PowerSync JavaScript/TypeScript SDK

Core patterns and guidance shared across all PowerSync JavaScript/TypeScript targets — schema design, the backend connector, database initialization, transactions, imperative queries, sync status, and debugging. For ORM integration see powersync-js-orm.md; for raw tables and sync internals see powersync-js-raw-tables.md.

Table of Contents

TypeScript-only exports: PowerSyncBackendConnector, PowerSyncCredentials, and AbstractPowerSyncDatabase are TypeScript interfaces only — they exist at compile time for type checking but have no runtime presence. This means:

  • Always use import type for these (e.g. import type { PowerSyncBackendConnector } from '@powersync/web')
  • Runtime checks like require('@powersync/web').PowerSyncBackendConnector will return undefined — this is expected, not a bug
  • Only UpdateType is a runtime value (enum) and uses a regular import
  • Bundlers like Vite will error if you import these without type since they try to resolve them as values
Resource Description
JS/TS Reference Full SDK documentation for Web, consult for details beyond the inline examples.
Web SDK API Reference Full API reference for @powersync/web, consult only when the inline examples don't cover your case.
React Native Reference Full SDK documentation for React Native, consult for details beyond the inline examples.
React Native SDK API Reference Full API reference for @powersync/react-native, consult only when the inline examples don't cover your case.
Capacitor Reference Full SDK documentation for Capacitor, consult for details beyond the inline examples.
Capacitor SDK API Reference Full API reference for @powersync/capacitor, consult only when the inline examples don't cover your case.
Node.js Reference Full SDK documentation for Node.js, consult for details beyond the inline examples.
Node.js SDK API Reference Full API reference for @powersync/node, consult only when the inline examples don't cover your case.
Supported Platforms - JS SDK Supported platforms and features, consult for compatibility details.

Framework-specific files (load alongside this file):

File Use when...
references/sdks/powersync-js-react.md React web app or Next.js
references/sdks/powersync-js-react-native.md React Native, Expo, or Expo Go
references/sdks/powersync-js-vue.md Vue or Nuxt
references/sdks/powersync-js-node.md Node.js CLI/server or Electron
references/sdks/powersync-js-tanstack.md TanStack Query or TanStack DB (any framework)

Package Coverage

Need Package
Web browser @powersync/web
React Native @powersync/react-native
Node.js/CLI @powersync/node
Capacitor @powersync/capacitor
React hooks @powersync/react
Vue composables @powersync/vue
Nuxt module @powersync/nuxt
TanStack Query (React) @powersync/tanstack-react-query
TanStack DB (multi-framework) @tanstack/powersync-db-collection
ORM @powersync/drizzle-driver or @powersync/kysely-driver

Quick Setup

1. Install

# Web
npm install @powersync/web@latest
npm install @journeyapps/wa-sqlite@latest # Needed (peer-dependency)

# React Native
npm install @powersync/react-native@latest
npm install @powersync/powersync-op-sqlite@latest  # Needed (peer-dependency)

# Node.js
npm install @powersync/node@latest
npm install better-sqlite3 # Needed (peer-dependency)

# React integration
npm install @powersync/react@latest

# Vue
npm install @powersync/vue@latest

# Nuxt (includes @powersync/vue — npm v7+ installs peers automatically)
npm install @powersync/nuxt@latest

# TanStack Query (React)
npm install @powersync/tanstack-react-query@latest

# TanStack DB
npm install @tanstack/powersync-db-collection@latest

Always install packages using @latest as shown above — PowerSync releases frequently and older cached versions can be missing critical fixes. Do not write version strings into package.json manually.

See the framework-specific files for full setup instructions per target.

2. Define Schema

import { column, Schema, Table } from '@powersync/web'; // or @powersync/react-native / @powersync/common

const todos = new Table(
  {
    // Do NOT define 'id' — PowerSync creates it automatically as TEXT PRIMARY KEY
    list_id: column.text,
    created_at: column.text,   // Store dates as ISO strings — no date type
    description: column.text,
    completed: column.integer, // Store booleans as 0/1 — no boolean type
  },
  {
    indexes: { list: ['list_id'] }, // Optional SQLite index
  }
);

export const AppSchema = new Schema({ todos, lists });
export type Database = (typeof AppSchema)['types'];
export type Todo = Database['todos']; // Auto-generated row type

Column types: only column.text, column.integer, column.real. No boolean, no date, no JSON native type — store those as text/integer.

No migrations — schema changes apply automatically on next open. Removed columns become inaccessible (data still in DB). New columns start null. Renaming = adding new + removing old (data loss). See Define the Client-Side Schema for more information.

Special Table Types

See Local-Only Tables and Insert-Only Tables for more information.

// Local-only — not synced from server, not uploaded, persists across restarts
const drafts = new Table({ title: column.text }, { localOnly: true });

// Insert-only — writes are uploaded but server never sends deletes
const logs = new Table({ message: column.text }, { insertOnly: true });

// Track previous values — available as op.previousValues in uploadData
const todos = new Table(
  { description: column.text, completed: column.integer },
  {
    trackPreviousValues: true,
    // or: trackPreviousValues: { columns: ['completed'] }
    // or: trackPreviousValues: { columns: ['completed'], onlyWhenChanged: true }
  }
);

// Track metadata attached to individual writes
const tasks = new Table(
  { title: column.text },
  { trackMetadata: true }  // Adds _metadata column, available as op.metadata in uploadData
);

3. Create Backend Connector

See Integrate with your Backend and Client-Side Integration for more information.

import type { PowerSyncBackendConnector, PowerSyncCredentials } from '@powersync/web'
async fetchCredentials(): Promise<PowerSyncCredentials> {
  return {
    endpoint: 'https://your-instance.powersync.journeyapps.com',
    token: await getJwtFromAuthService(),
    expiresAt: new Date(Date.now() + 3600_000), // optional hint for refresh timing
  };
}

fetchCredentials is called automatically every few minutes when the sync stream reconnects. Must always return fresh credentials — do not return stale cached tokens.

PowerSyncCredentials interface: { endpoint: string; token: string; expiresAt?: Date }. See Authentication Setup for configuring JWT authentication.

uploadData

Called automatically whenever local writes are pending. Must be synchronous with the actual backend write — do not queue operations for async processing elsewhere. If it throws, PowerSync backs off and retries automatically. See Writing Client-Side Changes to your Backend for more information.

import { UpdateType } from '@powersync/web'
import type { AbstractPowerSyncDatabase, PowerSyncBackendConnector, PowerSyncCredentials } from '@powersync/web'

async uploadData(database: AbstractPowerSyncDatabase): Promise<void> {
  const transaction = await database.getNextCrudTransaction();
  if (!transaction) return;

  try {
    for (const op of transaction.crud) {
      switch (op.op) {
        case UpdateType.PUT:
          await api.create(op.table, { id: op.id, ...op.opData });
          break;
        case UpdateType.PATCH:
          await api.update(op.table, op.id, op.opData);
          break;
        case UpdateType.DELETE:
          await api.delete(op.table, op.id);
          break;
      }
    }
    // MUST call complete() to advance the queue to the next transaction
    await transaction.complete();
  } catch (ex) {
    // Throw to retry later — PowerSync will back off and retry
    throw ex;
  }
}

If transaction.complete() is never called, getNextCrudTransaction() returns the same transaction forever — the upload queue stalls permanently.

Note: When uploading to backends with native boolean columns (e.g. PostgreSQL via Supabase or MongoDB), op.opData will contain 0/1. Convert before writing.

HTTP Status Code Handling

  • Return 2xx from backend even for validation errors — a 4xx blocks the upload queue permanently
  • 5xx → PowerSync retries automatically with backoff
  • Surface validation errors by writing them to a local-only table and showing in the UI — never let them block the queue

getCrudBatch vs getNextCrudTransaction

Two ways to consume the upload queue:

// getNextCrudTransaction — exactly one transaction's worth, all entries share transactionId
const tx = await db.getNextCrudTransaction();
if (tx) {
  for (const op of tx.crud) { /* op.transactionId is the same for all */ }
  await tx.complete();
}

// getCrudBatch — up to N entries, may span multiple transactions
const batch = await db.getCrudBatch(100);
if (batch) {
  for (const op of batch.crud) { /* may have different transactionIds */ }
  await batch.complete();
  // batch.haveMore === true means there are more entries waiting
}

getNextCrudTransaction is used in most connector examples — simpler, guarantees atomicity per write transaction. getCrudBatch is useful when you want to batch across transaction boundaries for backend throughput.

CrudEntry Fields

interface CrudEntry {
  clientId: number;                     // Auto-incrementing local ID
  id: string;                           // Row ID
  op: UpdateType;                       // PUT | PATCH | DELETE
  opData?: Record<string, any>;         // Changed columns — undefined for DELETE
  previousValues?: Record<string, any>; // Previous values — requires trackPreviousValues on table
  table: string;                        // Table name
  transactionId?: number;               // Groups ops from the same writeTransaction()
  metadata?: string;                    // Custom metadata — requires trackMetadata on table
}

Op types (UpdateType enum):

  • PUT — full insert or replace (new row, or complete overwrite)
  • PATCH — partial update (opData contains only the changed columns)
  • DELETE — deletion (opData is undefined)

previousValues is populated for PATCH and DELETE ops when the table has trackPreviousValues: true. Useful for implementing last-write-wins conflict resolution on the backend.

4. Initialize Database and Connect

See Instantiate the PowerSync Database for more information.

// 1. Instantiate — schema applied at construction, no migrations
const db = new PowerSyncDatabase({ schema, database: { dbFilename: 'app.db' } });

// 2. Connect — starts sync stream and uploadData loop in background
db.connect(connector);

// 3. Optionally wait for first sync before rendering data
await db.waitForFirstSync();

connect() does not block — sync happens in the background. Do NOT await connect() thinking data is ready after it returns.

5. Provider / Plugin Setup

Framework-specific setup (React PowerSyncContext.Provider, Vue plugin, Nuxt plugin) is covered in the framework files. See references/sdks/powersync-js-react.md, references/sdks/powersync-js-vue.md, etc.

Web-Specific Options

const db = new PowerSyncDatabase({
  schema,
  database: {
    dbFilename: 'app.db',
    debugMode: true        // Logs all SQL to Chrome DevTools Performance timeline
  },
  flags: {
    useWebWorker: true,    // Default true — runs DB in a web worker
    enableMultiTabs: true  // Default true — shares sync worker across tabs
  }
});

Multi-tab behavior: By default the web SDK uses a shared sync worker so all tabs share sync state. Only the most recently opened tab runs fetchCredentials and uploadData. Disable with enableMultiTabs: false if causing issues — but then only the oldest tab syncs.

VFS Options

VFS Option Description Reference URL
IDBBatchAtomicVFS Default Link
OPFSCoopSyncVFS Recommended Link
// Recommended — more reliable across browsers including Safari
import { WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'

const db = new PowerSyncDatabase({
  schema,
  database: new WASQLiteOpenFactory({
    dbFilename: 'app.db',
    vfs: WASQLiteVFS.OPFSCoopSyncVFS, // default: IDBBatchAtomicVFS
  }),
})

Safari: Requires OPFSCoopSyncVFS for stable multi-tab, or set useWebWorker: false. See Web SDK Reference for full configuration options.

Query Patterns

See Using PowerSync: CRUD functions for the full API reference.

useQuery

useQuery<RowType>(
  query: string | CompilableQuery<RowType>,
  parameters?: any[],
  options?: {
    rowComparator?: { keyBy, compareBy },
    reportFetching?: boolean,
    throttleMs?: number,
    runQueryOnce?: boolean,
    streams?: QuerySyncStreamOptions[],
  }
): { data, isLoading, isFetching, error }

Parameters are compared by JSON.stringify value, not by reference — so [userId] across renders are considered equal even as different array instances.

Pitfall: Avoid passing objects that serialize differently between renders (e.g. objects with changing key order).

rowComparator — Differential Mode

Without rowComparator: every change to any watched table re-runs the query and returns a new array reference — all children re-render regardless of whether their row changed.

With rowComparator: uses differential watch internally. Only rows that actually changed get new object references. Unchanged rows keep the same object reference, so React.memo can skip re-rendering them.

const { data: lists } = useQuery('SELECT * FROM lists', [], {
  rowComparator: {
    keyBy: (row) => row.id,
    compareBy: (row) => JSON.stringify(row)
  }
});

const ListItem = React.memo(({ list }) => <Text>{list.name}</Text>);
// Only re-renders when list.name or other fields actually change

runQueryOnce

Runs the query once after sync completes — no live watch. Useful for aggregations or reports.

const { data } = useQuery('SELECT COUNT(*) as total FROM lists', [], { runQueryOnce: true });

streams option

Gates the query on specific named sync streams having synced before executing.

const { data } = useQuery('SELECT * FROM lists', [], {
  streams: [{ name: 'lists', parameters: { userId }, waitForStream: true }]
});
// Returns isLoading: true until the 'lists' stream has synced

Compiling Queries (CompilableQuery)

Both hooks accept a CompilableQuery object in addition to a plain SQL string. This is useful when using Drizzle or Kysely integrations:

// With Drizzle
const query = db.select().from(lists).where(eq(lists.ownerId, userId));
const { data } = useQuery(query);

// With Kysely
const query = db.selectFrom('lists').selectAll().where('owner_id', '=', userId);
const { data } = useQuery(query);

One-Time Queries (Imperative)

// Get all
const todos = await db.getAll('SELECT * FROM todos WHERE list_id = ?', [listId]);

// Get one (throws if not found)
const todo = await db.get('SELECT * FROM todos WHERE id = ?', [id]);

// Get optional (returns null if not found)
const todo = await db.getOptional('SELECT * FROM todos WHERE id = ?', [id]);

Watch Queries (Imperative)

Outside of React, use the async generator API directly:

for await (const result of db.watchWithAsyncGenerator('SELECT * FROM lists')) {
  console.log(result.rows._array);
}

// Or with a differential watch (only emits on actual changes)
const watchedQuery = db.customQuery({
  compile: () => ({ sql: 'SELECT * FROM lists', parameters: [] }),
  execute: () => db.getAll('SELECT * FROM lists')
}).watch({ reportFetching: true });

const dispose = watchedQuery.registerListener({
  onData: (data) => console.log(data),
  onError: (err) => console.error(err)
});

// Later:
dispose();

Writes & Transactions

Single Operation

await db.execute(
  'INSERT INTO lists (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)',
  ['My List', userId]
);

Use when multiple operations must be atomic. Auto-commits on success, auto-rollbacks if an exception is thrown.

await db.writeTransaction(async (tx) => {
  await tx.execute('DELETE FROM lists WHERE id = ?', [listId]);
  await tx.execute('DELETE FROM todos WHERE list_id = ?', [listId]);
  // No need to call commit() — it's automatic
});

When to use writeTransaction:

  • Multiple operations that must succeed or fail together
  • Cascading deletes, multi-table updates
  • Any situation where partial completion would leave data inconsistent

When NOT to use:

  • Single operations — db.execute() is simpler and faster
  • Read-only queries — use readTransaction or getAll/get

readTransaction

const result = await db.readTransaction(async (tx) => {
  const lists = await tx.getAll('SELECT * FROM lists');
  const count = await tx.get('SELECT COUNT(*) as n FROM todos');
  return { lists, count };
});

ID Generation

PowerSync auto-creates an id TEXT column on every table — do not declare it in the schema. Generate UUIDs client-side:

// SQLite uuid() function is available
await db.execute('INSERT INTO todos (id, description) VALUES (uuid(), ?)', ['Buy milk']);

// Or generate in JS
import { v4 as uuidv4 } from 'uuid';
await db.execute('INSERT INTO todos (id, description) VALUES (?, ?)', [uuidv4(), 'Buy milk']);

Lock Behavior

  • Only ONE write transaction executes at a time (global write mutex)
  • Default lock timeout: 120 seconds — increase only if operations genuinely take longer
  • writeTransaction() takes the lock for the entire callback duration
  • Multiple rapid writes are more efficient when batched inside a single writeTransaction

Sync Status, Priorities & Sync Streams

useStatus Hook

const status = useStatus();
// {
//   connected: boolean,
//   connecting: boolean,
//   lastSyncedAt: Date | null,
//   hasSynced: boolean,          // true after first full sync, persists across restarts
//   isSyncing: boolean,
//   downloadProgress: DownloadProgress | null,
//   dataFlowStatus: {
//     uploading: boolean,
//     downloading: boolean,
//     uploadError: Error | undefined,    // set on upload failure, cleared on next success
//     downloadError: Error | undefined,  // set on download/connect failure, cleared on next success
//     downloadProgress: ...
//   }
// }

uploadError and downloadError

status.dataFlowStatus.uploadError and status.dataFlowStatus.downloadError are the primary way to surface sync failures to users or logging systems.

  • uploadError — set when an exception occurs during the CRUD upload loop. Cleared automatically on the next successful upload.
  • downloadError — set when an exception occurs during the streaming sync (including connection failures). Cleared on the next successful data download or checkpoint completion.
const status = useStatus();

if (status.dataFlowStatus?.uploadError) {
  return <Banner>Failed to save changes: {status.dataFlowStatus.uploadError.message}</Banner>;
}
if (status.dataFlowStatus?.downloadError) {
  return <Banner>Sync error: {status.dataFlowStatus.downloadError.message}</Banner>;
}

Register a status listener imperatively (useful for logging, not just UI):

db.registerListener({
  statusChanged: (status) => {
    if (status.dataFlowStatus?.downloadError) {
      logger.error('PowerSync download failed', {
        error: status.dataFlowStatus.downloadError,
        lastSyncedAt: status.lastSyncedAt,
        connected: status.connected,
      });
    }
    if (status.dataFlowStatus?.uploadError) {
      logger.error('PowerSync upload failed', {
        error: status.dataFlowStatus.uploadError,
        lastSyncedAt: status.lastSyncedAt,
        connected: status.connected,
      });
    }
  }
});

waitForFirstSync

db.waitForFirstSync() resolves when all data has been downloaded at least once. After that, db.currentStatus.hasSynced is true and persists across app restarts (stored in the local DB).

// Standard usage — gate app rendering behind first sync
db.connect(connector);
await db.waitForFirstSync();
// Now safe to render data-dependent screens

// With abort signal (e.g. timeout after 10s)
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
await db.waitForFirstSync(controller.signal);

Sync Priorities

Streams (or buckets in legacy Sync Rules) can be assigned priorities (0-3). Lower numbers = higher priority. Higher-priority data syncs first, allowing partial data to appear before the full sync completes. See Prioritized Sync for more information.

Priority 0 is special: it syncs regardless of pending uploads — use carefully as it can cause temporary inconsistencies.

Consistency caveat: Full PowerSync consistency guarantees (including deletes) only apply once ALL buckets at all priorities have synced. Higher-priority partial syncs may have stale deletes until lower-priority buckets complete.

waitForFirstSync with Priority

// Wait only for priority-1 buckets (faster — show UI sooner)
await db.waitForFirstSync({ priority: 1 });

// With abort signal + priority
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
await db.waitForFirstSync({ signal: controller.signal, priority: 1 });

Download Progress UI

const status = useStatus();
const progress = status.downloadProgress;

// Overall progress
if (progress) {
  return <ProgressBar value={progress.downloadedFraction} />;
  // progress.downloadedOperations / progress.totalOperations also available
}

// Progress up to a specific priority only
const priorityProgress = progress?.untilPriority(1);
if (priorityProgress) {
  return <ProgressBar value={priorityProgress.downloadedFraction} />;
}

Per-Priority Status

// Check if a specific priority has synced
const p1Status = db.currentStatus.statusForPriority(1);
// p1Status.hasSynced, p1Status.lastSyncedAt

// List all priority statuses seen
const entries = db.currentStatus.priorityStatusEntries();
// [{ priority: 1, hasSynced: true, lastSyncedAt: Date }, ...]

Sync Streams

Sync Streams are the recommended way to define what data syncs to each client. They provide on-demand subscriptions with parameters and TTL-based expiry. See sync-config.md for server-side configuration (YAML definitions, parameters, CTEs).

Requires the service to be configured with Sync Streams (edition 3 config). See Sync Streams Overview and Client-Side Usage for more information.

The streams option in useQuery (see below) and the imperative API work across all JS/TS frameworks. Framework-specific Sync Stream hooks are covered in the respective framework files where available — for example, useSyncStream and useSuspenseSyncStream in references/sdks/powersync-js-react.md.

Streams in useQuery

Gate a query on a specific stream having synced, without managing the subscription manually:

const { data: lists } = useQuery('SELECT * FROM lists', [], {
  streams: [
    {
      name: 'lists',
      parameters: { userId },
      waitForStream: true,  // hold isLoading: true until this stream syncs
      priority: 1,
      ttl: 3600,
    }
  ]
});

Imperative API

// Subscribe directly
const subscription = await db.syncStream('lists', { userId }).subscribe({
  priority: 1,
  ttl: 3600
});

// Wait for this specific stream to sync
await subscription.waitForFirstSync();

// Check status
const streamStatus = db.currentStatus.forStream(subscription);
console.log(streamStatus.subscription.hasSynced);

// Unsubscribe when done
subscription.unsubscribe();

Stream Gotchas

  • Parameters as identity: same stream name with different parameters = separate subscriptions
  • Partial checkpoints: only the Rust sync client supports partial checkpoints (priority-level consistency)
  • Default streams: server may configure streams as default — these subscribe automatically without a client call
  • TTL eviction: after TTL expires with no active subscriber, the stream's data may be removed from the local DB

ORM & Raw Tables

These advanced topics are in separate files — load only when needed:

Topic File Load when…
Drizzle / Kysely ORM references/sdks/powersync-js-orm.md Using Drizzle or Kysely for type-safe queries
Raw Tables references/raw-tables.md Need native SQLite tables (SDK-agnostic — JS, Dart, Kotlin, Swift, Rust)

JS Internals

Only needed when debugging QueryStore eviction, investigating sync client implementations, or working with internal op types.

Sync Client Implementations

The Rust-based sync client is now the only sync client. The legacy JavaScript client (SyncClientImplementation.JAVASCRIPT / @LegacySyncImplementation APIs) has been removed from @powersync/common, @powersync/web, and @powersync/react-native and is no longer an option. Do not downgrade the SDK to a version that still shipped the JS client — older versions can't read the Rust client's storage format.

QueryStore

useSuspenseQuery uses a QueryStore (one per PowerSyncDatabase, stored in a WeakMap). Caches WatchedQuery instances keyed by "${sql} -- ${JSON.stringify(params)} -- ${JSON.stringify(options)}". Evicted when listener count reaches 0. useSuspenseQuery and useQuery with the same SQL/params/options share the same underlying WatchedQuery.

Op Types (Internal Sync vs CRUD)

Internal bucket ops (OpTypeEnum): CLEAR=1, MOVE=2, PUT=3, REMOVE=4 — sync protocol only, not exposed to userland. CRUD upload ops (UpdateType): PUT, PATCH, DELETE — what you see in uploadData. Don't confuse sync-level REMOVE with CRUD-level DELETE.

Debugging

See Debugging Overview for the full list of tools and techniques.

Sync Diagnostics Client

https://diagnostics-app.powersync.com

Connect this to a running PowerSync instance to inspect tables, rows, sync buckets, and run arbitrary SQL against the local database. This is the fastest way to isolate whether a problem is in the PowerSync service or in the client:

  • If the Sync Diagnostics Client shows the correct data → the service is syncing correctly → the issue is in your client code (query, schema, rendering)
  • If the Sync Diagnostics Client shows incorrect or missing data → the issue is in the PowerSync service configuration (sync rules, backend connector, permissions)

Enable SDK Logging (Development)

import { createBaseLogger, LogLevel } from '@powersync/react'; // or @powersync/common

const logger = createBaseLogger();
logger.useDefaults(); // output to console
logger.setLevel(LogLevel.DEBUG); // DEBUG | INFO | WARN | ERROR | TRACE | OFF

Production Logging

Enable PowerSync logging in production — it is extremely helpful for debugging sync issues reported by users. Use whatever logging provider your app already uses (Sentry, Datadog, Firebase Crashlytics, etc.).

The key pattern is: use WARN level in production (captures errors and warnings without noise), and pipe warnings/errors to your log aggregation service. Capture all levels as breadcrumbs so you have context leading up to an error.

Example using Sentry (substitute your own provider):

import { createBaseLogger, LogLevel } from '@powersync/react-native';

const logger = createBaseLogger();
logger.useDefaults();
logger.setLevel(LogLevel.WARN); // WARN and above in production

logger.setHandler((messages, context) => {
  if (!context?.level) return;

  const messageArray = Array.from(messages);
  const mainMessage = String(messageArray[0] || '');
  const extra = messageArray.slice(1).reduce((acc, curr) => ({ ...acc, ...curr }), {});
  const level = context.level.name.toLowerCase();

  // Capture everything as breadcrumbs for pre-error context
  Sentry.addBreadcrumb({
    message: mainMessage,
    level: level as Sentry.SeverityLevel,
    data: extra,
    timestamp: Date.now()
  });

  // Only send warn/error to the logging service
  if (level === 'warn' || level === 'error') {
    Sentry.logger[level](mainMessage, extra);
  }
});

Also register a status listener to capture uploadError and downloadError — these won't appear in the SDK logger automatically:

db.registerListener({
  statusChanged: (status) => {
    if (status.dataFlowStatus?.downloadError) {
      logger.error('PowerSync download error', {
        error: status.dataFlowStatus.downloadError,
        lastSyncedAt: status.lastSyncedAt,
        connected: status.connected,
        sdkVersion: db.sdkVersion,
      });
    }
    if (status.dataFlowStatus?.uploadError) {
      logger.error('PowerSync upload error', {
        error: status.dataFlowStatus.uploadError,
        lastSyncedAt: status.lastSyncedAt,
        connected: status.connected,
        sdkVersion: db.sdkVersion,
      });
    }
  }
});

Context to include in logs: user/session ID, SDK version (db.sdkVersion), lastSyncedAt, connected status. Avoid logging sensitive row data.

Web: SQL Logging to Chrome Performance Timeline

const db = new PowerSyncDatabase({
  schema,
  database: { dbFilename: 'app.db', debugMode: true }
});
// All SQL appears in Chrome DevTools → Performance tab timeline

Check Sync Status Imperatively

console.log(db.currentStatus);
// { connected, connecting, lastSyncedAt, hasSynced, isSyncing, downloadProgress }

Common Pitfalls

See also Error Codes Reference for PowerSync service error codes.

1. All children re-render on every table change

Without rowComparator, every write to a watched table returns a new array — all children re-render even if their row didn't change.

// BAD — all ListItem components re-render on any lists write
const { data: lists } = useQuery('SELECT * FROM lists');
return lists.map(l => <ListItem list={l} />);

// GOOD — only changed rows get new references, React.memo skips the rest
const { data: lists } = useQuery('SELECT * FROM lists', [], {
  rowComparator: { keyBy: r => r.id, compareBy: r => JSON.stringify(r) }
});
const ListItem = React.memo(({ list }) => <Text>{list.name}</Text>);

2. Awaiting connect() thinking data is ready

// WRONG — connect() is fire-and-forget, data is NOT available after await
await db.connect(connector);
renderApp(); // may show empty data

// CORRECT
db.connect(connector);
await db.waitForFirstSync(); // wait for data
renderApp();

3. Schema ID column

// WRONG
const todos = new Table({ id: column.text, description: column.text });

// RIGHT — 'id' is auto-created by PowerSync
const todos = new Table({ description: column.text });

4. uploadData queue stuck

If transaction.complete() is never called, getNextCrudTransaction() returns the same transaction forever. The upload queue stalls permanently. Always call complete(), even on partial failure if you want to skip a bad transaction.

5. Web: SQLite library conflicts

If another SQLite package exists in the project (sql.js, better-sqlite3, etc.), it can conflict with PowerSync's SQLite engine. Remove all other SQLite libraries. Symptom: "Could not load extension" error.

6. useQuery data seems stale / not updating

  • Verify the table name in SQL exactly matches the schema key (case-sensitive)
  • Writes must go through db.execute() or writeTransaction() — writes via raw SQLite connections bypass PowerSync's change tracking
  • Check db.currentStatus.connected — if false, sync isn't running