Files
shiftcraft/.claude/skills/pocketbase-best-practices/rules/realtime-subscribe.md
2026-04-17 23:26:01 +00:00

5.0 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Implement Realtime Subscriptions Correctly MEDIUM Live updates without polling, reduced server load realtime, subscriptions, sse, websocket

Implement Realtime Subscriptions Correctly

PocketBase uses Server-Sent Events (SSE) for realtime updates. Proper subscription management prevents memory leaks and ensures reliable event delivery.

Incorrect (memory leaks and poor management):

// Missing unsubscribe - memory leak!
function PostList() {
  useEffect(() => {
    pb.collection('posts').subscribe('*', (e) => {
      updatePosts(e);
    });
    // No cleanup - subscription persists forever!
  }, []);
}

// Subscribing multiple times
function loadPosts() {
  // Called on every render - creates duplicate subscriptions!
  pb.collection('posts').subscribe('*', handleChange);
}

// Not handling reconnection
pb.collection('posts').subscribe('*', (e) => {
  // Assumes connection is always stable
  updateUI(e);
});

Correct (proper subscription management):

// React example with cleanup
function PostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    // Initial load
    loadPosts();

    // Subscribe to changes
    const unsubscribe = pb.collection('posts').subscribe('*', (e) => {
      if (e.action === 'create') {
        setPosts(prev => [e.record, ...prev]);
      } else if (e.action === 'update') {
        setPosts(prev => prev.map(p =>
          p.id === e.record.id ? e.record : p
        ));
      } else if (e.action === 'delete') {
        setPosts(prev => prev.filter(p => p.id !== e.record.id));
      }
    });

    // Cleanup on unmount
    return () => {
      unsubscribe();
    };
  }, []);

  async function loadPosts() {
    const result = await pb.collection('posts').getList(1, 50);
    setPosts(result.items);
  }

  return <PostListUI posts={posts} />;
}

// Subscribe to specific record
async function watchPost(postId) {
  return pb.collection('posts').subscribe(postId, (e) => {
    console.log('Post changed:', e.action, e.record);
  });
}

// Subscribe to collection changes
async function watchAllPosts() {
  return pb.collection('posts').subscribe('*', (e) => {
    console.log(`Post ${e.action}:`, e.record.title);
  });
}

// Handle connection events
pb.realtime.subscribe('PB_CONNECT', (e) => {
  console.log('Realtime connected, client ID:', e.clientId);
  // Re-sync data after reconnection
  refreshData();
});

// Vanilla JS with proper cleanup
class PostManager {
  unsubscribes = [];

  async init() {
    this.unsubscribes.push(
      await pb.collection('posts').subscribe('*', this.handlePostChange)
    );
    this.unsubscribes.push(
      await pb.collection('comments').subscribe('*', this.handleCommentChange)
    );
  }

  destroy() {
    this.unsubscribes.forEach(unsub => unsub());
    this.unsubscribes = [];
  }

  handlePostChange = (e) => { /* ... */ };
  handleCommentChange = (e) => { /* ... */ };
}

Subscription event structure:

pb.collection('posts').subscribe('*', (event) => {
  event.action;  // 'create' | 'update' | 'delete'
  event.record;  // The affected record
});

// Full event type
interface RealtimeEvent {
  action: 'create' | 'update' | 'delete';
  record: RecordModel;
}

Unsubscribe patterns:

// Unsubscribe from specific callback
const unsub = await pb.collection('posts').subscribe('*', callback);
unsub();  // Remove this specific subscription

// Unsubscribe from all subscriptions on a topic
pb.collection('posts').unsubscribe('*');  // All collection subs
pb.collection('posts').unsubscribe('RECORD_ID');  // Specific record

// Unsubscribe from all collection subscriptions
pb.collection('posts').unsubscribe();

// Unsubscribe from everything
pb.realtime.unsubscribe();

Performance considerations:

// Prefer specific record subscriptions over collection-wide when possible.
// subscribe('*') checks ListRule for every connected client on each change.
// subscribe(recordId) checks ViewRule -- fewer records to evaluate.

// For high-traffic collections, subscribe to specific records:
await pb.collection('orders').subscribe(orderId, handleOrderUpdate);
// Instead of: pb.collection('orders').subscribe('*', handleAllOrders);

// Use subscription options to reduce payload size (SDK v0.21+):
await pb.collection('posts').subscribe('*', handleChange, {
  fields: 'id,title,updated',  // Only receive specific fields
  expand: 'author',            // Include expanded relations
  filter: 'status = "published"'  // Only receive matching records
});

Subscription scope guidelines:

Scenario Recommended Scope
Watching a specific document subscribe(recordId)
Chat room messages subscribe('*') with filter for room
User notifications subscribe('*') with filter for user
Admin dashboard subscribe('*') (need to see all)
High-frequency data (IoT) subscribe(recordId) per device

Reference: PocketBase Realtime