Understanding Real-time Subscriptions
Real-time subscriptions use Firestore’sonSnapshot listener to receive updates whenever matching documents are added, modified, or removed.
How It Works
Real-time listeners charge you for every document in the initial result set, plus every document that changes. Use filters to minimize costs.
Basic Subscriptions
Subscribe to Query Results
const unsubscribe = await orderRepo.query()
.where('status', '==', 'active')
.onSnapshot(
(orders) => {
console.log(`Active orders: ${orders.length}`);
updateDashboard(orders);
},
(error) => {
console.error('Snapshot error:', error);
}
);
// Later: stop listening
unsubscribe();
With Error Handling
const unsubscribe = await userRepo.query()
.where('status', '==', 'online')
.onSnapshot(
(users) => {
console.log(`${users.length} users online`);
setOnlineUsers(users);
},
(error) => {
console.error('Failed to subscribe:', error);
showErrorNotification('Connection lost');
}
);
Real-World Use Cases
Live Dashboard
class DashboardService {
private unsubscribers: Array<() => void> = [];
async startLiveUpdates() {
// Subscribe to active orders
const ordersUnsub = await orderRepo.query()
.where('status', 'in', ['pending', 'processing'])
.onSnapshot(
(orders) => {
this.updateOrdersWidget(orders);
},
(error) => console.error('Orders subscription error:', error)
);
this.unsubscribers.push(ordersUnsub);
// Subscribe to today's revenue
const revenueUnsub = await orderRepo.query()
.where('status', '==', 'completed')
.where('createdAt', '>=', startOfToday())
.onSnapshot(
(orders) => {
const total = orders.reduce((sum, o) => sum + o.total, 0);
this.updateRevenueWidget(total);
},
(error) => console.error('Revenue subscription error:', error)
);
this.unsubscribers.push(revenueUnsub);
// Subscribe to online users
const usersUnsub = await userRepo.query()
.where('status', '==', 'online')
.onSnapshot(
(users) => {
this.updateOnlineUsersWidget(users.length);
},
(error) => console.error('Users subscription error:', error)
);
this.unsubscribers.push(usersUnsub);
}
stopLiveUpdates() {
// Unsubscribe from all listeners
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
}
private updateOrdersWidget(orders: Order[]) {
// Update UI
}
private updateRevenueWidget(total: number) {
// Update UI
}
private updateOnlineUsersWidget(count: number) {
// Update UI
}
}
Chat Application
class ChatService {
private unsubscribe?: () => void;
subscribeToMessages(
roomId: string,
onMessagesUpdate: (messages: Message[]) => void
) {
this.unsubscribe = await messageRepo.query()
.where('roomId', '==', roomId)
.orderBy('createdAt', 'desc')
.limit(50)
.onSnapshot(
(messages) => {
onMessagesUpdate(messages);
},
(error) => {
console.error('Messages subscription error:', error);
}
);
}
async sendMessage(roomId: string, text: string, userId: string) {
await messageRepo.create({
roomId,
text,
userId,
createdAt: new Date().toISOString()
});
// Subscribers automatically get the new message
}
disconnect() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
}
Notification System
class NotificationService {
private unsubscribe?: () => void;
subscribeToNotifications(
userId: string,
onNewNotification: (notification: Notification) => void
) {
let lastNotificationCount = 0;
this.unsubscribe = await notificationRepo.query()
.where('userId', '==', userId)
.where('read', '==', false)
.orderBy('createdAt', 'desc')
.onSnapshot(
(notifications) => {
// Check if new notifications arrived
if (notifications.length > lastNotificationCount) {
const newOnes = notifications.slice(
0,
notifications.length - lastNotificationCount
);
newOnes.forEach(notification => {
onNewNotification(notification);
this.showBrowserNotification(notification);
});
}
lastNotificationCount = notifications.length;
this.updateBadgeCount(notifications.length);
},
(error) => console.error('Notifications error:', error)
);
}
private showBrowserNotification(notification: Notification) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
});
}
}
private updateBadgeCount(count: number) {
// Update UI badge
}
disconnect() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
Presence System
class PresenceService {
private unsubscribe?: () => void;
private heartbeatInterval?: NodeJS.Timeout;
async goOnline(userId: string) {
// Mark user as online
await userRepo.update(userId, {
status: 'online',
lastSeen: new Date().toISOString()
});
// Send heartbeat every 30 seconds
this.heartbeatInterval = setInterval(async () => {
await userRepo.update(userId, {
lastSeen: new Date().toISOString()
});
}, 30000);
}
async goOffline(userId: string) {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
await userRepo.update(userId, {
status: 'offline',
lastSeen: new Date().toISOString()
});
}
subscribeToOnlineUsers(
onUpdate: (users: User[]) => void
) {
this.unsubscribe = await userRepo.query()
.where('status', '==', 'online')
.onSnapshot(
(users) => {
onUpdate(users);
},
(error) => console.error('Presence error:', error)
);
}
disconnect() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
Collaborative Editing
class CollaborationService {
private unsubscribe?: () => void;
subscribeToDocument(
documentId: string,
onUpdate: (doc: Document, editors: string[]) => void
) {
// Subscribe to document changes
this.unsubscribe = await documentRepo.query()
.where('id', '==', documentId)
.onSnapshot(
async (docs) => {
if (docs.length === 0) return;
const doc = docs[0];
// Get active editors
const editorsSnapshot = await editorRepo.query()
.where('documentId', '==', documentId)
.where('lastSeen', '>', fiveMinutesAgo())
.get();
const editors = editorsSnapshot.map(e => e.userId);
onUpdate(doc, editors);
},
(error) => console.error('Document subscription error:', error)
);
}
async updatePresence(documentId: string, userId: string) {
await editorRepo.upsert(`${documentId}-${userId}`, {
documentId,
userId,
lastSeen: new Date().toISOString()
});
}
disconnect() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
React Integration
Custom Hook
import { useState, useEffect } from 'react';
function useRealtimeQuery<T>(query: FirestoreQueryBuilder<T>) {
const [data, setData] = useState<(T & { id: string })[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
async function subscribe() {
try {
unsubscribe = await query.onSnapshot(
(items) => {
setData(items);
setLoading(false);
},
(err) => {
setError(err);
setLoading(false);
}
);
} catch (err) {
setError(err as Error);
setLoading(false);
}
}
subscribe();
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, []);
return { data, loading, error };
}
Usage in Component
function OrderDashboard() {
const { data: orders, loading, error } = useRealtimeQuery(
orderRepo.query()
.where('status', '==', 'active')
.orderBy('createdAt', 'desc')
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>Active Orders ({orders.length})</h1>
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
);
}
Multiple Subscriptions
function Dashboard() {
const { data: activeOrders } = useRealtimeQuery(
orderRepo.query().where('status', '==', 'active')
);
const { data: onlineUsers } = useRealtimeQuery(
userRepo.query().where('status', '==', 'online')
);
const { data: recentMessages } = useRealtimeQuery(
messageRepo.query()
.orderBy('createdAt', 'desc')
.limit(10)
);
return (
<div>
<StatsCard title="Active Orders" value={activeOrders.length} />
<StatsCard title="Online Users" value={onlineUsers.length} />
<MessageList messages={recentMessages} />
</div>
);
}
Performance Optimization
Limit Result Size
// ✅ Good - limited results
const unsubscribe = await messageRepo.query()
.where('roomId', '==', roomId)
.orderBy('createdAt', 'desc')
.limit(50) // Only last 50 messages
.onSnapshot(callback);
// ❌ Bad - potentially thousands of documents
const unsubscribe = await messageRepo.query()
.where('roomId', '==', roomId)
.onSnapshot(callback);
Use Narrow Filters
// ✅ Good - specific filter
const unsubscribe = await orderRepo.query()
.where('userId', '==', currentUserId)
.where('status', 'in', ['pending', 'processing'])
.onSnapshot(callback);
// ❌ Bad - all orders
const unsubscribe = await orderRepo.query()
.onSnapshot(callback);
Unsubscribe When Not Needed
class ComponentLifecycle {
private unsubscribe?: () => void;
async onMount() {
this.unsubscribe = await orderRepo.query()
.where('userId', '==', userId)
.onSnapshot(this.handleOrders);
}
onUnmount() {
// IMPORTANT: Clean up subscription
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
Cost Management
Understanding Costs
// Initial subscription: 100 documents
const unsubscribe = await orderRepo.query()
.where('status', '==', 'active')
.onSnapshot(callback);
// Cost: 100 document reads immediately
// 1 order updated
// Cost: +1 document read
// 5 new orders created
// Cost: +5 document reads
// Total so far: 106 document reads
Real-time listeners can get expensive quickly if documents change frequently. Monitor your usage and use filters wisely.
Cost Reduction Strategies
Use Aggressive Filters
Only subscribe to exactly what you need.
// ✅ Specific to user
.where('userId', '==', currentUserId)
.where('status', '==', 'active')
Limit Results
Use
.limit() to cap the number of documents..orderBy('createdAt', 'desc')
.limit(20) // Only 20 most recent
Unsubscribe Aggressively
Stop listening when user navigates away.
useEffect(() => {
const unsub = subscribe();
return () => unsub(); // Cleanup
}, []);
Error Handling
Handle Connection Errors
const unsubscribe = await orderRepo.query()
.where('status', '==', 'active')
.onSnapshot(
(orders) => {
setOrders(orders);
setConnectionStatus('connected');
},
(error) => {
console.error('Subscription error:', error);
setConnectionStatus('disconnected');
// Show user notification
showNotification('Connection lost. Retrying...');
// Optionally retry
setTimeout(() => {
resubscribe();
}, 5000);
}
);
Automatic Reconnection
class RealtimeConnection {
private unsubscribe?: () => void;
private retryCount = 0;
private maxRetries = 5;
async subscribe() {
try {
this.unsubscribe = await orderRepo.query()
.where('userId', '==', userId)
.onSnapshot(
(orders) => {
this.handleOrders(orders);
this.retryCount = 0; // Reset on success
},
(error) => {
this.handleError(error);
}
);
} catch (error) {
this.handleError(error);
}
}
private handleError(error: any) {
console.error('Subscription error:', error);
if (this.retryCount < this.maxRetries) {
this.retryCount++;
const delay = Math.pow(2, this.retryCount) * 1000;
console.log(`Retrying in ${delay}ms (attempt ${this.retryCount})`);
setTimeout(() => {
this.subscribe();
}, delay);
} else {
console.error('Max retries reached. Giving up.');
}
}
disconnect() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
Best Practices
Always Unsubscribe
Prevent memory leaks by cleaning up subscriptions.
// ✅ Good
const unsub = await repo.query().onSnapshot(callback);
// Later:
unsub();
// ❌ Bad - memory leak
await repo.query().onSnapshot(callback);
// Never unsubscribes
Use Specific Filters
Minimize the number of documents in your subscription.
// ✅ Good - user-specific
.where('userId', '==', currentUserId)
.limit(20)
// ❌ Bad - entire collection
.onSnapshot(callback)
Handle Errors
Always provide an error handler.
await repo.query().onSnapshot(
(data) => handleData(data),
(error) => handleError(error) // ✅ Error handler
);
Next Steps
Queries
Build queries for real-time subscriptions
Performance
Optimize real-time subscription costs
Streaming
Alternative approach for large datasets
Error Handling
Handle connection errors gracefully