Skip to main content

Performance Best Practices

Follow these guidelines to optimize table performance in your SpacetimeDB modules.

Use Indexes for Lookups

Generally prefer indexed lookups over full table scans:

Good - Using an index:

// Fast: Uses unique index on name
ctx.db.player.name.filter('Alice')

Avoid - Full table scan:

// Slow: Iterates through all rows
Array.from(ctx.db.player.iter())
  .find(p => p.name === 'Alice')

Add indexes to columns you frequently filter or join on. See Indexes for details.

Keep Tables Focused

Break large tables into smaller, more focused tables when appropriate:

Instead of one large table:

const player = table(
  { name: 'player' },
  {
    id: t.u32(),
    name: t.string(),
    // Game state
    position_x: t.f32(),
    position_y: t.f32(),
    health: t.u32(),
    // Statistics (rarely accessed)
    total_kills: t.u32(),
    total_deaths: t.u32(),
    play_time_seconds: t.u64(),
    // Settings (rarely changed)
    audio_volume: t.f32(),
    graphics_quality: t.u8(),
  }
);

Consider splitting into multiple tables:

const player = table(
  { name: 'player' },
  {
    id: t.u32().primaryKey(),
    name: t.string().unique(),
  }
);

const playerState = table(
  { name: 'player_state' },
  {
    player_id: t.u32().unique(),
    position_x: t.f32(),
    position_y: t.f32(),
    health: t.u32(),
  }
);

const playerStats = table(
  { name: 'player_stats' },
  {
    player_id: t.u32().unique(),
    total_kills: t.u32(),
    total_deaths: t.u32(),
    play_time_seconds: t.u64(),
  }
);

const playerSettings = table(
  { name: 'player_settings' },
  {
    player_id: t.u32().unique(),
    audio_volume: t.f32(),
    graphics_quality: t.u8(),
  }
);

Benefits:

  • Reduces data transferred to clients who don't need all fields
  • Allows more targeted subscriptions
  • Improves update performance by touching fewer rows
  • Makes the schema easier to understand and maintain

Choose Appropriate Types

Use the smallest integer type that fits your data range:

// If you only need 0-255, use u8 instead of u64
level: t.u8(),           // Not t.u64()
player_count: t.u16(),   // Not t.u64()
entity_id: t.u32(),      // Not t.u64()

This reduces:

  • Memory usage
  • Network bandwidth
  • Storage requirements

Consider Table Visibility

Private tables avoid unnecessary client synchronization overhead:

// Public table - clients can subscribe and receive updates
const player = table(
  { name: 'player', public: true },
  { /* ... */ }
);

// Private table - only visible to module and owner
// Better for internal state, caches, or sensitive data
const internalState = table(
  { name: 'internal_state' },
  { /* ... */ }
);

Make tables public only when clients need to access them. Private tables:

  • Don't consume client bandwidth
  • Don't require client-side storage
  • Are hidden from non-owner queries

Batch Operations

When inserting or updating multiple rows, batch them in a single reducer call rather than making multiple reducer calls:

Good - Batch operation:

spacetimedb.reducer('spawn_enemies', { count: t.u32() }, (ctx, { count }) => {
  for (let i = 0; i < count; i++) {
    ctx.db.enemy.insert({
      id: 0, // auto_inc
      health: 100,
    });
  }
});

Avoid - Multiple calls:

// Client makes 10 separate reducer calls
for (let i = 0; i < 10; i++) {
  connection.reducers.spawnEnemy();
}

Batch operations are more efficient because:

  • Single transaction reduces overhead
  • Reduced network round trips
  • Better database performance

Monitor Table Growth

Be mindful of unbounded table growth:

  • Implement cleanup reducers for temporary data
  • Archive or delete old records
  • Use scheduled tables to automatically expire data
  • Consider pagination for large result sets

Next Steps

  • Learn about Indexes to optimize queries
  • Explore Subscriptions for efficient client data sync
  • Review Reducers for efficient data modification patterns