> ## Documentation Index
> Fetch the complete documentation index at: https://docs.powersync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Experimental: High Performance Diffs

> Get row-level change notifications using trigger-based table diffs in JavaScript SDKs.

# Overview

While [basic/incremental watch queries](/client-sdks/watch-queries) enable reactive UIs by automatically re‑running queries when underlying data changes and returning updated results, they don't specify which individual rows were modified. To get these details, you can use [**differential watch queries**](/client-sdks/watch-queries#differential-watch-queries), which return a structured diff between successive query results. However, on large result sets they can be slow because they re‑run the query and compare full results (e.g., scanning \~1,000 rows to detect 1 new item). That’s why we introduced **trigger‑based table diffs**: a more performant approach that uses SQLite triggers to record changes on a table as they happen. This means that the overhead associated with tracking these changes overhead is more proportional to the number of rows inserted, updated, or deleted.

<Note>
  **JavaScript Only**: Trigger-based table diffs are currently only supported in our JavaScript SDKs, starting from:

  * Web v1.26.0
  * React Native v1.24.0
  * Node.js v0.10.0
</Note>

<Warning>
  The `db.triggers` APIs are experimental. We're actively seeking feedback on:

  * API design and developer experience
  * Additional features or optimizations needed

  Join our [Discord community](https://discord.gg/powersync) to share your experience and get help.
</Warning>

## Comparison: Trigger-Based Diffs vs Differential Watch Queries

* **Scope**: Trigger-based diffs track row-level changes on a single table. Differential watches work with arbitrary query results (including joins).
* **Overhead**: Trigger-based diffs do per-row work at write time (overhead grows with number of affected rows). Differential watches re-query and compare result sets on each change (overhead grows with result set size).
* **Processing path**: Trigger-based diffs record changes at write time and require a `writeLock` during processing (only a single `writeLock` is allowed). Differential watches run on read connections and re-query/compare results on each change (often concurrent on some platforms).
* **Storage/shape**: Trigger-based diffs store changes as rows in a temporary SQLite table that you can query with SQL. Differential watch diffs are exposed to app code as JS objects/arrays.
* **Filtering**: Trigger-based diffs can filter/skip storing diff records inside the SQLite trigger, which prevents emissions on a lower level. Differential watches query the SQLite DB on any change to the query's dependent tables, and the changes are filtered after querying SQLite.

**In summary**: Differential watch queries are the most flexible since they work with arbitrary multi-table queries, but they can be slow on large result sets. For those cases, trigger-based diffs are more efficient, though they only track a single table and add some write overhead.

## Trigger-Based Diffs

Trigger-based diffs create temporary SQLite triggers and a temporary table to record row‑level inserts, updates, and deletes as they happen. You can then query the diff table with SQL to process the changes.

<Note>
  **SQLite triggers and PowerSync views**

  In PowerSync, the tables you define in the client schema are exposed as SQLite views. The actual data is stored in underlying SQLite tables, with each row's values encoded as JSON (commonly in a single `data` column).

  SQLite cannot attach triggers to INSERT/UPDATE/DELETE operations on views — triggers must target the underlying base tables. The `db.triggers` API handles these details for you:

  * You can reference the view name in `source`; PowerSync resolves and targets the corresponding underlying table internally.
  * Column filters are applied by inspecting JSON changes in the underlying row and determining whether the configured columns changed.
  * Diff rows can be queried as if they were real columns (not raw JSON) using the `withExtractedDiff(...)` helper.

  You can also create your own triggers manually (for example, as shown in the [Full‑Text Search example](/client-sdks/full-text-search)), but be mindful of the view/trigger limitation and target the underlying table rather than the view.
</Note>

## Tracking and Reacting to Changes (Recommended)

The primary API is `trackTableDiff`. It wraps the lower-level trigger setup, automatically manages a `writeLock` during processing, exposes a `DIFF` table alias to join against, and cleans up when you call the returned `stop()` function. Think of it as an automatic "watch" that processes diffs as they occur.

```javascript theme={null}
const stop = await db.triggers.trackTableDiff({
  // PowerSync source table/view to trigger and track changes from.
  // This should be present in the PowerSync database's schema.
  source: 'todos',
  // Specifies which columns from the source table to track in the diff records.
  // Defaults to all columns in the source table.
  // Use an empty array to track only the ID and operation.
  columns: ['list_id'],
  // Required WHEN clause per operation to filter inside the trigger. Use 'TRUE' to track all.
  when: { INSERT: sanitizeSQL`json_extract(NEW.data, '$.list_id') = ${firstList.id}` },
  onChange: async (context) => {
    // // Fetches the todo records that were inserted during this diff
    const newTodos = await context.withDiff(/* sql */ `
      SELECT todos.*
      FROM DIFF
      JOIN todos ON DIFF.id = todos.id
    `);

    // Handle new todos here
  }
});

// Later, dispose triggers and internal resources
await stop();
```

### Filtering with `when`

The required `when` parameter lets you add conditions that determine when the triggers should fire. This corresponds to a SQLite [WHEN](https://sqlite.org/lang_createtrigger.html) clause in the trigger body.

* Use `NEW` for `INSERT`/`UPDATE` and `OLD` for `DELETE`.
* Row data is stored as JSON in the `data` column; the row identifier is `id`.
* Use `json_extract(NEW.data, '$.column')` or `json_extract(OLD.data, '$.column')` to reference logical columns.
* Set the clause to `'TRUE'` to track all changes for a given operation.

Example:

```javascript theme={null}
const stop = await db.triggers.trackTableDiff({
  source: 'todos',
  when: {
    // Track all INSERTs
    INSERT: 'TRUE',
    // Only UPDATEs where status becomes 'active' for a specific record
    UPDATE: sanitizeSQL`NEW.id = ${sanitizeUUID('abcd')} AND json_extract(NEW.data, '$.status') = 'active'`,
    // Only DELETEs for a specific list
    DELETE: sanitizeSQL`json_extract(OLD.data, '$.list_id') = 'abcd'`
  }
});
```

<Warning>
  The strings in `when` are embedded directly into the SQLite trigger creation SQL. Sanitize any user‑derived values. The `sanitizeSQL` helper performs some basic sanitization; additional sanitization is recommended.
</Warning>

## Lower-level: createDiffTrigger (advanced)

Set up temporary triggers that write change operations into a temporary table you control. Prefer `trackTableDiff` unless you need to manage lifecycle and locking manually (e.g., buffer diffs to process them later). Note that since the table is created as a temporary table on the SQLite write connection, it can only be accessed within operations performed inside a `writeLock`.

```javascript theme={null}
// Define the temporary table to store the diff
const tempTable = 'listsDiff';

// Configure triggers to record INSERT and UPDATE operations on `lists`
const dispose = await db.triggers.createDiffTrigger({
  // PowerSync source table/view to trigger and track changes from.
  // This should be present in the PowerSync database's schema.
  source: 'lists',
  // Destination table to send changes to.
  // This table is created internally as a SQLite temporary table.
  // This table will be dropped once the trigger is removed.
  destination: tempTable,
  // Required WHEN clause per operation to filter inside the trigger. Use 'TRUE' to track all.
  when: {
    INSERT: 'TRUE',
    UPDATE: sanitizeSQL`json_extract(NEW.data, '$.name') IS NOT NULL`
  },
  // Specifies which columns from the source table to track in the diff records.
  // Defaults to all columns in the source table.
  // Use an empty array to track only the ID and operation.
  columns: ['name']
});

// ... perform writes on `lists` ...

// Consume and clear changes within a writeLock
await db.writeLock(async (tx) => {
  const changes = await tx.getAll(/* sql */ `
    SELECT * FROM ${tempTable}
  `);

  // Process changes here

  // Clear after processing
  await tx.execute(/* sql */ `DELETE FROM ${tempTable};`);
});

// Later, clean up triggers and temp table
await dispose();
```
