Skip to main content

Troubleshooting

This guide covers common issues you may encounter when building with Zero, along with debugging strategies and solutions.

Common Issues

SQLite NULL + OR = Full Table Scan

Problem: Queries with OR conditions involving NULL values perform full table scans instead of using indexes. Symptoms:
  • Query that should be fast (< 1ms) takes 100-300ms
  • Performance degrades with table size
  • EXPLAIN QUERY PLAN shows SCAN instead of SEARCH
Example:
// If email is NULL, this becomes a full table scan:
SELECT * FROM users WHERE id = ? OR email = ?;

// SQLite abandons MULTI-INDEX OR optimization
Root cause: When any branch of an OR query involves a NULL value, SQLite falls back to a full table scan, even for valid branches. Solution: Filter out NULL values before building OR queries:
// Filter out keys where any column is NULL
const validKeys = keys.filter(key =>
  key.every(column => row[column] !== null && row[column] !== undefined)
);

// Build query only with valid keys
const orConditions = validKeys.map(key => /* build condition */);
Impact: This fix can improve performance by 320x (from 320ms to 1ms). Reference: Known Gotchas in the monorepo.

Query Not Updating

Problem: UI doesn’t reflect data changes even though mutations succeed. Possible causes:
  1. Query not subscribed to changes:
    // Wrong: Manual fetch doesn't subscribe
    const data = await z.query.users.fetch();
    
    // Right: run() subscribes to updates
    const {data} = z.query.users.run();
    
  2. Query destroyed prematurely:
    const {data, cleanup} = z.query.users.run();
    
    // Don't call cleanup while still using the query!
    // cleanup();
    
  3. Mutation not pushed to server:
    // Check mutation returns successfully
    try {
      await z.mutate.updateUser({id, name});
    } catch (error) {
      console.error('Mutation failed:', error);
    }
    
  4. Schema mismatch between client and server:
    • Verify client and server use the same schema version
    • Check for schema migration issues
Debugging:
// Enable debug logging
import {DebugDelegate} from 'zql/builder/debug-delegate';

const debugDelegate = {
  logPush(table: string, change: SourceChange) {
    console.log(`Push to ${table}:`, change);
  },
  logFetch(table: string, constraint?: Constraint) {
    console.log(`Fetch from ${table}:`, constraint);
  }
};

// Pass to query builder
const query = z.query.users.build(/* ... */, debugDelegate);

Memory Leaks

Problem: Memory usage grows over time, eventually causing performance degradation or crashes. Symptoms:
  • Increasing memory usage in browser dev tools
  • Slowing performance over time
  • “Out of memory” errors
Common causes:
  1. Queries not cleaned up:
    // In React component
    useEffect(() => {
      const {data, cleanup} = z.query.users.run();
      
      // Must cleanup on unmount!
      return cleanup;
    }, []);
    
  2. Event listeners not removed:
    // Remove listeners when done
    const unsubscribe = z.subscribe('users', handler);
    
    // Later:
    unsubscribe();
    
  3. Large result sets retained in memory:
    // Problem: Fetching all rows
    const {data} = z.query.posts.run();
    
    // Solution: Use pagination
    const {data} = z.query.posts.limit(50).run();
    
Debugging:
  1. Use browser memory profiler:
    • Chrome DevTools → Memory → Take heap snapshot
    • Look for growing object counts (especially Node, Operator instances)
  2. Check for retained queries:
    // Track active queries
    const activeQueries = new Set();
    
    function createQuery() {
      const {data, cleanup} = z.query.users.run();
      activeQueries.add(cleanup);
      
      return () => {
        cleanup();
        activeQueries.delete(cleanup);
      };
    }
    
    // Monitor size
    console.log('Active queries:', activeQueries.size);
    

Slow Query Performance

Problem: Queries take longer than expected to materialize or update. Diagnosis steps:
  1. Measure materialization time:
    const start = performance.now();
    const {data} = z.query.users.whereExists('posts').run();
    console.log(`Materialized in ${performance.now() - start}ms`);
    
  2. Check for missing indexes:
    -- List indexes for a table
    SELECT name, sql FROM sqlite_master 
    WHERE type='index' AND tbl_name='posts';
    
    -- Create missing indexes on foreign keys
    CREATE INDEX idx_posts_userId ON posts(userId);
    
  3. Verify ANALYZE has been run:
    -- Check if statistics exist
    SELECT COUNT(*) FROM sqlite_stat1;
    
    -- If 0, run ANALYZE
    ANALYZE;
    
  4. Analyze query plan:
    import {AccumulatorDebugger} from 'zql/planner-debug';
    
    const debugger = new AccumulatorDebugger();
    planQuery(ast, costModel, debugger);
    
    console.log(debugger.format());
    // Shows:
    // - Which join strategy was chosen
    // - Cost estimates for each plan
    // - Constraint propagation
    
  5. Profile with OpenTelemetry:
    // Metrics automatically tracked:
    // - query-materialization-server
    // - query-update-server
    // - query-materialization-client
    // - query-update-client
    
Common solutions:
  • Add indexes on join columns
  • Run ANALYZE to update statistics
  • Add filters to reduce result set size
  • Use limits to cap result count
  • Restructure deeply nested queries

Type Errors

Problem: TypeScript compilation errors in query or mutation code. Common issues:
  1. Schema type mismatch:
    // Schema defines optional field
    const user = table('user').columns({
      name: string().optional(), // Note: optional
    });
    
    // Type system requires handling undefined
    const name: string = user.name; // ❌ Error
    const name: string | undefined = user.name; // ✅ Correct
    
  2. Incorrect query builder types:
    // Wrong: Type doesn't match schema
    z.query.users.where('age', '>', '25'); // ❌ age is number, not string
    
    // Right: Correct type
    z.query.users.where('age', '>', 25); // ✅
    
  3. Missing type imports:
    // Use import type for type-only imports
    import type {Schema} from 'zero-types/schema';
    import type {AST} from 'zero-protocol/ast';
    
Note: Zero requires optional fields to be explicitly typed as type | undefined, not just type?:
// Correct
interface User {
  name?: string | undefined;
}

// Incorrect (will cause type errors)
interface User {
  name?: string;
}

Mutation Conflicts

Problem: Mutations fail with conflict errors or produce unexpected results. Symptoms:
  • “Version mismatch” errors
  • Mutations silently failing
  • Data reverting after mutation
Common causes:
  1. Concurrent edits:
    // Two clients edit same row simultaneously
    // Client A: update({id: 1, name: 'Alice'})
    // Client B: update({id: 1, name: 'Bob'})
    // → Conflict!
    
  2. Stale client state:
    • Client offline for extended period
    • Server state changed significantly
    • Client needs to re-sync
  3. Invalid mutation logic:
    // Custom mutation with incorrect logic
    z.defineMutation('updateUser', async (tx, {id, name}) => {
      // Missing validation or conflict handling
      await tx.update('user', {id, name});
    });
    
Solutions:
  1. Implement optimistic UI with rollback:
    try {
      await z.mutate.updateUser({id, name});
    } catch (error) {
      if (error.code === 'CONFLICT') {
        // Refresh client state
        await z.refresh();
        // Optionally retry
      }
    }
    
  2. Use last-write-wins semantics when appropriate:
    // For non-critical fields
    z.defineMutation('updatePreference', async (tx, {key, value}) => {
      await tx.upsert('preference', {key, value});
    });
    
  3. Add conflict resolution logic:
    z.defineMutation('incrementCounter', async (tx, {id}) => {
      const current = await tx.get('counter', id);
      await tx.update('counter', {
        id,
        value: (current?.value || 0) + 1
      });
    });
    

Debugging Techniques

Enable Debug Logging

Client-side:
// Set localStorage flag
localStorage.setItem('zero:debug', 'true');

// Or use debug build
import {setDebugMode} from '@rocicorp/zero-client';
setDebugMode(true);
Server-side (Zero Cache):
# Set environment variable
export DEBUG=zero:*

# Or use debug flag
npm run zero-cache-dev -- --debug

Inspect Query AST

const query = z.query.users.whereExists('posts');

// Get internal AST representation
const ast = query.toAST();
console.log(JSON.stringify(ast, null, 2));

// Shows:
// - Table and column references
// - Filter conditions
// - Join structures
// - Ordering and limits

Trace Operator Pipeline

import {DebugDelegate} from 'zql/builder/debug-delegate';

class TracingDelegate implements DebugDelegate {
  logFetch(table: string, constraint?: Constraint) {
    console.log(`[FETCH] ${table}`, constraint);
  }
  
  logPush(table: string, change: SourceChange) {
    console.log(`[PUSH] ${table}`, change.type, change.row);
  }
}

const query = buildQuery(ast, new TracingDelegate());

Use Browser DevTools

Network tab:
  • Monitor WebSocket messages (mutations, sync)
  • Check for failed requests
  • Verify timing and payload sizes
Performance tab:
  • Record query execution
  • Identify bottlenecks
  • Check for excessive re-renders
Memory tab:
  • Take heap snapshots
  • Compare snapshots over time
  • Find retained objects

Database Inspection

SQLite (client-side):
// In browser console
const db = await z._internal.getDB();

// Run raw SQL
const rows = db.exec('SELECT * FROM users LIMIT 10');
console.table(rows);

// Check indexes
const indexes = db.exec(
  'SELECT * FROM sqlite_master WHERE type="index"'
);
PostgreSQL (server-side):
-- Check table sizes
SELECT 
  schemaname,
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

-- Check for missing indexes on foreign keys
SELECT
  tc.table_name,
  kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND NOT EXISTS (
    SELECT 1 FROM pg_indexes
    WHERE tablename = tc.table_name
      AND indexdef LIKE '%' || kcu.column_name || '%'
  );

Test Isolation

Isolate the issue:
  1. Minimal reproduction:
    // Remove all non-essential code
    const {data} = z.query.users.run(); // Does this work?
    const {data} = z.query.users.where('active', true).run(); // How about this?
    
  2. Test with synthetic data:
    // Seed database with known data
    await z.mutate.createUser({id: '1', name: 'Test'});
    await z.mutate.createPost({id: '1', userId: '1', title: 'Test'});
    
    // Verify query behavior
    const {data} = z.query.users.whereExists('posts').run();
    expect(data).toHaveLength(1);
    
  3. Compare with direct SQL:
    // What does the database actually contain?
    const directResult = db.exec('SELECT * FROM users');
    console.log('Direct query:', directResult);
    
    // What does Zero return?
    const {data} = z.query.users.run();
    console.log('Zero query:', data);
    

Error Messages

QueryParseError

Message: Failed to parse arguments for query Cause: Invalid arguments passed to query builder Solution:
// Check argument types match schema
z.query.users.where('age', '>', 25); // age is number
z.query.users.where('name', '=', 'Alice'); // name is string

// Verify condition operators
z.query.users.where('active', '=', true); // Use '=' for equality
z.query.users.where('age', '>', 18); // Use '>' for comparison
Location: packages/zql/src/query/error.ts

Connection Errors

Message: Failed to connect to Zero cache Common causes:
  1. Zero cache not running
  2. Incorrect URL configuration
  3. Network issues
Solutions:
# Start Zero cache
npm run zero-cache-dev

# Check configuration
const z = new Zero({
  server: 'http://localhost:4848', // Verify URL
  // ...
});

# Check network in browser DevTools
# Look for failed WebSocket connections

Schema Errors

Message: Table 'xyz' not found in schema Cause: Query references table not in schema Solution:
// Verify table exists in schema
import {schema} from './schema';
console.log(Object.keys(schema.tables)); // List all tables

// Ensure client and server use same schema
// Check for typos in table names

Performance Debugging

Identify Bottlenecks

  1. Measure each operation:
    const timings = {};
    
    const t0 = performance.now();
    const {data} = z.query.users.run();
    timings.materialization = performance.now() - t0;
    
    const t1 = performance.now();
    await z.mutate.createUser({...});
    timings.mutation = performance.now() - t1;
    
    console.table(timings);
    
  2. Profile operator pipeline:
    // Wrap operators with timing
    class TimingOperator implements Operator {
      constructor(private inner: Operator, private name: string) {}
      
      fetch(req: FetchRequest) {
        const start = performance.now();
        const result = this.inner.fetch(req);
        console.log(`${this.name} fetch: ${performance.now() - start}ms`);
        return result;
      }
      
      push(change: Change) {
        const start = performance.now();
        const result = this.inner.push(change);
        console.log(`${this.name} push: ${performance.now() - start}ms`);
        return result;
      }
    }
    
  3. Monitor metrics:
    // Implement MetricsDelegate
    const metrics = {
      addMetric(metric, value, ...args) {
        console.log(`[METRIC] ${metric}: ${value}ms`, args);
      }
    };
    
    // Pass to query registry
    const registry = new QueryRegistry(schema, metrics);
    

Analyze Query Plans

import {AccumulatorDebugger} from 'zql/planner-debug';
import {buildPlanGraph} from 'zql/planner-builder';

const debugger = new AccumulatorDebugger();
const plans = buildPlanGraph(ast, costModel, true);
plans.plan.plan(debugger);

const output = debugger.format();
console.log(output);

// Shows:
// - All plans considered (2^n for n joins)
// - Cost estimate for each plan
// - Selected best plan
// - Constraint propagation
// - Fan-out factors
Interpret results:
  • High costs → Add indexes or filters
  • Wrong plan selected → Update statistics (ANALYZE)
  • All plans expensive → Restructure query

Common Error Patterns

Pattern: Edit Change Split

Symptom: Edit changes become remove + add in UI Cause: Filter condition changed or sort order affected Expected behavior:
// If row passes filter before and after edit:
// → Single edit change

// If row no longer passes filter:
// → Remove change

// If row newly passes filter:
// → Add change

// If sort key changed:
// → Remove + Add (relationships rebuilt)
Not a bug: This is correct incremental maintenance behavior. See packages/zql/src/ivm/view-apply-change.ts:46.

Pattern: Relationship Stream Exhausted

Symptom: Error when iterating relationship twice Cause: Relationship streams are single-use Solution:
// Wrong: Can't iterate twice
for (const post of user.posts) { /* ... */ }
for (const post of user.posts) { /* ... */ } // ❌ Error

// Right: Convert to array if needed
const posts = Array.from(user.posts);
for (const post of posts) { /* ... */ }
for (const post of posts) { /* ... */ } // ✅ Works

Pattern: Yield Not Propagated

Symptom: UI freezes during large operations Cause: Yield signals not propagated through pipeline Contract: If an operation yields 'yield', caller must yield immediately.
// Wrong: Ignoring yields
function* fetch(req) {
  for (const node of upstream.fetch(req)) {
    // Process node (ignoring 'yield')
    yield processNode(node);
  }
}

// Right: Propagate yields
function* fetch(req) {
  for (const node of upstream.fetch(req)) {
    if (node === 'yield') {
      yield 'yield'; // Propagate immediately
      continue;
    }
    yield processNode(node);
  }
}
See packages/zql/src/ivm/operator.ts:41.

Getting Help

Before Asking

  1. Search existing issues on GitHub
  2. Check this troubleshooting guide
  3. Review Performance best practices
  4. Try isolating the issue to minimal reproduction

What to Include

  1. Minimal reproduction:
    • Simplest code that shows the issue
    • Sample schema and data
    • Expected vs actual behavior
  2. Environment:
    • Zero version
    • Browser/Node.js version
    • Operating system
  3. Error messages:
    • Full stack trace
    • Console logs
    • Network errors (if applicable)
  4. What you’ve tried:
    • Steps already taken
    • Debugging output
    • Related issues checked

Resources

Next Steps

Build docs developers (and LLMs) love