Firestore real-time listeners at scale: connection management and cost control
A developer builds a dashboard application with Firestore real-time listeners throughout. Each component subscribes to its own listener on mount and unsubscribes on unmount. The architecture looks clean. During a load test simulating 500 concurrent users, the Firebase console shows 8,000 active Firestore connections. With 500 users, the expected connection count was closer to 500-1,000.
The difference: the dashboard page has 16 components, each with its own onSnapshot listener. 500 users × 16 components = 8,000 connections. When a user navigates within the dashboard (components remount), listeners are created and destroyed frequently, generating rapid connection churn that appears in the Firebase error logs.
Why per-component listeners are the wrong default
The instinct to put the listener in the component that uses the data feels correct — it is local, explicit, and easy to understand in isolation. The problem is that it does not compose. When 5 components each need data from the same Firestore collection, 5 listeners are created pointing at the same data. When 16 components each need data from different collections, 16 listeners are created per user session.
Each listener represents an active WebSocket connection (or a multiplexed channel over a shared connection). Firestore's SDK can multiplex listeners that use the same underlying connection, but listeners to different collections or different query parameters use different connection channels. At 16 listeners per user and 500 concurrent users, the connection count reflects this multiplication.
Beyond the connection count, per-component listeners create a subtle correctness problem: when a component unmounts and remounts (due to React re-renders, navigation, or conditional rendering), the listener is torn down and recreated. Each recreation charges for the initial snapshot again. A component that mounts and unmounts 10 times in a session generates 10 initial snapshot reads, each reading all documents in the query result.
The connection management problem
Firestore establishes a WebSocket connection per listener in most SDKs. Each connection has overhead — memory on the server and client, and a keep-alive cost. The SDK can multiplex multiple listeners over fewer connections, but the effectiveness of this multiplexing depends on how listeners are structured.
Listeners that target different collections or different query parameters cannot share a connection. A component tree with 16 independent listeners to 16 different collections establishes 16 connection paths.
Lifting listeners to the application level
For data that is used by multiple components on the same page, a single listener at the application level serves all components through React context:
// contexts/ProjectsContext.tsx
// Single listener that serves all components on the projects page
const ProjectsContext = createContext<{
projects: Project[];
loading: boolean;
}>({ projects: [], loading: true });
export function ProjectsProvider({ userId, children }: {
userId: string;
children: React.ReactNode;
}) {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const q = query(
collection(db, 'projects'),
where('memberIds', 'array-contains', userId),
orderBy('updatedAt', 'desc'),
limit(50)
);
// ONE listener serves ALL child components
const unsubscribe = onSnapshot(q, (snapshot) => {
setProjects(snapshot.docs.map(d => ({ id: d.id, ...d.data() } as Project)));
setLoading(false);
});
return unsubscribe; // Single cleanup point
}, [userId]);
return (
<ProjectsContext.Provider value={{ projects, loading }}>
{children}
</ProjectsContext.Provider>
);
}
// Any component can consume the data without creating a new listener
function ProjectCount() {
const { projects } = useContext(ProjectsContext);
return <span>{projects.length} projects</span>;
}
function ProjectList() {
const { projects, loading } = useContext(ProjectsContext);
if (loading) return <LoadingSkeleton />;
return <ul>{projects.map(p => <ProjectItem key={p.id} project={p} />)}</ul>;
}
One listener, many consumers. The 16-component problem becomes a 1-connection page.
The provider wraps the page-level component, not the root. Listeners should be scoped to where the data is used — a listener that is open for the entire session when it is only needed on one page wastes connections and reads during every other page visit.
A listener registry for complex applications
For applications with many pages and shared data requirements, a listener registry manages the lifecycle of listeners centrally:
// ListenerRegistry — manages listeners across the application
class FirestoreListenerRegistry {
private listeners = new Map<string, {
unsubscribe: () => void;
refCount: number;
}>();
subscribe<T>(
key: string,
buildQuery: () => Query,
onUpdate: (data: T[]) => void
): () => void {
if (this.listeners.has(key)) {
// Already subscribed — increment ref count
this.listeners.get(key)!.refCount++;
} else {
// First subscriber — create the listener
const unsubscribe = onSnapshot(buildQuery(), (snapshot) => {
onUpdate(snapshot.docs.map(d => d.data() as T));
});
this.listeners.set(key, { unsubscribe, refCount: 1 });
}
// Return unsubscribe function for this subscriber
return () => {
const entry = this.listeners.get(key);
if (!entry) return;
entry.refCount--;
if (entry.refCount === 0) {
entry.unsubscribe();
this.listeners.delete(key);
}
};
}
}
export const listenerRegistry = new FirestoreListenerRegistry();
The registry deduplicates listeners: if two components subscribe to the same key, one listener serves both. When both components unsubscribe, the listener closes. This is reference-counted cleanup.
Subscription lifecycle management
The most common cause of listener leaks: components that subscribe but never unsubscribe because the cleanup function is not returned or not called.
// LEAKS: useEffect without cleanup
useEffect(() => {
onSnapshot(doc(db, 'orders', orderId), (doc) => {
setOrder(doc.data());
});
// No return — listener runs forever, even after component unmounts
}, [orderId]);
// CORRECT: Return the unsubscribe function
useEffect(() => {
const unsubscribe = onSnapshot(doc(db, 'orders', orderId), (doc) => {
setOrder(doc.data());
});
return unsubscribe; // Called when component unmounts or orderId changes
}, [orderId]);
React StrictMode in development mounts and unmounts components twice to detect cleanup issues. If a component has an infinite loop or accumulating subscriptions in development under StrictMode, the cleanup function is not working correctly.
A custom hook that enforces cleanup:
// useFirestoreDoc — enforces cleanup, handles loading and error states
function useFirestoreDoc<T>(
docPath: string
): { data: T | null; loading: boolean; error: Error | null } {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({ data: null, loading: true, error: null });
useEffect(() => {
const unsubscribe = onSnapshot(
doc(db, docPath),
(snapshot) => {
setState({
data: snapshot.exists() ? (snapshot.data() as T) : null,
loading: false,
error: null,
});
},
(error) => {
setState({ data: null, loading: false, error });
}
);
return unsubscribe; // Always returned — cleanup is guaranteed
}, [docPath]);
return state;
}
Scoping listener queries
Large result sets are expensive. A listener on a collection with 100,000 documents transfers all 100,000 documents on the initial snapshot, then sends updates for every document change. Scope listeners to the data the user actually needs:
// EXPENSIVE: Listen to all orders (could be millions)
const q = collection(db, 'orders');
// SCOPED: Listen only to this user's orders from the last 30 days
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const q = query(
collection(db, 'orders'),
where('userId', '==', currentUser.uid),
where('createdAt', '>', Timestamp.fromDate(thirtyDaysAgo)),
orderBy('createdAt', 'desc'),
limit(50) // Cap the initial result set
);
The limit(50) cap is important: it bounds the initial read cost regardless of how many orders the user has.
Pausing listeners when not in use
For SPAs where pages mount and unmount, listeners should be paused when the page is not visible:
function useVisibilityAwareListener<T>(
buildQuery: () => ReturnType<typeof query>,
onUpdate: (data: T[]) => void
) {
const unsubscribeRef = useRef<(() => void) | null>(null);
const subscribe = useCallback(() => {
if (unsubscribeRef.current) return; // Already subscribed
unsubscribeRef.current = onSnapshot(buildQuery(), (snapshot) => {
onUpdate(snapshot.docs.map(d => d.data() as T));
});
}, [buildQuery, onUpdate]);
const unsubscribe = useCallback(() => {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
unsubscribe(); // Pause when tab is not visible
} else {
subscribe(); // Resume when tab becomes visible
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
subscribe(); // Subscribe on mount
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
unsubscribe(); // Unsubscribe on unmount
};
}, [subscribe, unsubscribe]);
}
A user with 10 open browser tabs running the same SPA generates 10× the Firestore connections. Pausing listeners on hidden tabs reduces this significantly. When the tab becomes visible again, the listener reopens and the initial snapshot delivers any changes that occurred while the listener was paused. The gap between close and reopen means the client catches up on missed changes on the next subscription open — Firestore's internal change tracking ensures the resumed listener delivers a complete current state, not just changes since the pause.
Detecting and debugging listener leaks in production
Development tools catch listener leaks during testing. Production leaks require monitoring. A proxy that counts active listeners and reports via analytics:
let listenerCount = 0;
const originalOnSnapshot = onSnapshot;
// Wrap onSnapshot to track opens and closes
export function instrumentedOnSnapshot(
ref: DocumentReference | Query,
handler: (snapshot: any) => void
): () => void {
listenerCount++;
if (listenerCount > 20) {
// Alert if too many listeners open simultaneously
analytics.logEvent('high_listener_count', { count: listenerCount });
}
const unsubscribe = originalOnSnapshot(ref as any, handler);
return () => {
listenerCount--;
unsubscribe();
};
}
A session with a listener count that grows monotonically — never decreasing as the user navigates — has a leak. A count that oscillates around a stable value (rises on page view, falls on navigation away) is correct.
Estimating listener costs
Listener cost has three components:
- Initial snapshot: number of documents in the query result × $0.06 per 100,000 reads
- Listen operations: billed for each active listener, per minute (varies by region)
- Document change events: number of documents changed while listener is active × $0.06 per 100,000 reads
For a dashboard with 10 listeners serving 1,000 daily active users, each user active for 2 hours:
- Initial snapshots: 1,000 users × 10 listeners × 50 documents avg = 500,000 reads/day
- Document changes: depends on write frequency in those collections
The 8,000-connection dashboard was generating 80× the expected initial snapshot reads. Consolidating 16 component listeners into 3 page-level context providers reduced connections to ~1,500 and reads proportionally. The application was still correct — the architecture became cheaper without changing what users saw.
The correct target architecture: one listener per logical data domain per page, managed at the page or feature level (not the component level), cleaned up reliably on navigation, and scoped to the minimum result set needed. When that structure is in place, connection counts match user counts and read costs match data activity — not the component tree depth.
Listener error handling in production
Listeners can fail. Network errors, permission errors, and quota errors all produce an error callback that most implementations leave as console.error. In production, listener errors should be handled explicitly:
useEffect(() => {
const q = query(
collection(db, 'orders'),
where('userId', '==', userId),
limit(20)
);
const unsubscribe = onSnapshot(
q,
(snapshot) => {
setOrders(snapshot.docs.map(d => ({ id: d.id, ...d.data() })));
setError(null);
},
(error) => {
// PERMISSION_DENIED — user's auth state changed, or security rules blocked the query
if (error.code === 'permission-denied') {
setError('Access denied. Please sign in again.');
signOut(auth);
return;
}
// RESOURCE_EXHAUSTED — Firestore quota exceeded
if (error.code === 'resource-exhausted') {
setError('Service temporarily unavailable.');
// Do not retry immediately — back off and use cached data
return;
}
// Network errors — listener will auto-retry; show offline indicator
setOffline(true);
}
);
return unsubscribe;
}, [userId]);
A listener that encounters a permission-denied error does not automatically retry — it stops. The application must handle this by re-establishing the listener after correcting the authentication state (re-sign-in, token refresh). A listener that encounters a network error does automatically retry with backoff, but the application should show an offline indicator during the retry period rather than silently showing stale data without any indication.
Coordinating listeners with server-side writes
A common pattern: a Cloud Function writes a document, and a client listener should reflect that write quickly. Firestore's real-time infrastructure delivers updates to listeners within seconds of the server write — typically under 500ms in normal conditions. Applications should not need to poll or use additional signaling for this pattern.
However, when a Cloud Function write and a client write happen close together, the listener may receive the documents in an order that does not match causality:
// Client writes a "processing" status, then Cloud Function writes "completed"
// The listener may briefly show "completed" before it shows "processing"
// if the Cloud Function is faster than the client's write confirmation
// Solution: use FieldValue.serverTimestamp() on both writes
// and sort on the timestamp in the listener
const q = query(
collection(db, 'orderStatuses'),
where('orderId', '==', orderId),
orderBy('updatedAt', 'desc'),
limit(1)
);
Ordering by server timestamp ensures the listener always shows the most recent state, even if the listener receives updates out of the causal order of writes. The initial snapshot after an out-of-order delivery may be momentarily incorrect, but the subsequent delivery will correct it.
Listener behavior in React Native vs. web
The Firestore SDK behaves identically in React Native and web for most listener operations, but connection management differs in one important way: React Native applications continue running in the background when the app is suspended. Listeners that are not paused when the app enters the background continue generating reads and holding connections, even though no user is actively looking at the data.
// React Native — pause listeners when app is backgrounded
import { AppState, AppStateStatus } from 'react-native';
const unsubscribeRef = useRef<(() => void) | null>(null);
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'background' || nextState === 'inactive') {
// App is going to background — pause listener
unsubscribeRef.current?.();
unsubscribeRef.current = null;
} else if (nextState === 'active' && !unsubscribeRef.current) {
// App returned to foreground — resume listener
unsubscribeRef.current = onSnapshot(buildQuery(), handler);
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
unsubscribeRef.current = onSnapshot(buildQuery(), handler);
return () => {
subscription.remove();
unsubscribeRef.current?.();
};
}, []);
This pattern is the React Native equivalent of the web visibility change handler. For mobile applications where users frequently switch between apps (checking a message, returning to the app), the listener re-establishes quickly and delivers the current state. The reads generated by frequent app switches are bounded by the query's limit clause, making the visibility-aware pattern essential for controlling read costs on mobile.