Firestore has one of the most convenient real-time APIs in web and mobile development.
We write a listener:
import { collection, onSnapshot, query, where } from "firebase/firestore";
const q = query(
collection(db, "messages"),
where("roomId", "==", "general")
);
const unsubscribe = onSnapshot(q, (snapshot) => {
snapshot.docChanges().forEach((change) => {
console.log(change.type, change.doc.id, change.doc.data());
});
});
After that, the UI updates whenever matching documents are added, modified, or removed.
It feels similar to a WebSocket because the browser receives real-time updates without manually polling. But Firestore listeners and WebSockets are not the same abstraction.
A WebSocket gives us a low-level bidirectional pipe.
A Firestore listener gives us a database-backed synchronization API with query semantics, local cache behavior, metadata events, retries, security rules, and billing rules.
In this post, we will look at how Firestore listeners are implemented conceptually, how they compare to WebSockets, and what we should check before choosing one approach over the other.
The Short Version
Firestore listeners are not “just WebSockets exposed as a Firebase API.”
The useful mental model is:
Application code
|
| onSnapshot(query)
v
Firestore SDK
|
| local cache + pending writes + watch stream
v
Firestore backend
|
| document/query changes
v
Application callback
The SDK hides most of the hard parts:
- opening and maintaining the network stream
- sending listen targets for documents or queries
- applying document changes to a local view
- firing an initial snapshot
- marking local writes as pending
- retrying transient failures
- resuming streams when possible
- surfacing metadata such as cache state and pending writes
With WebSockets, we build most of that application protocol ourselves.
What Firestore onSnapshot Does
The public API is simple.
For one document:
import { doc, onSnapshot } from "firebase/firestore";
const unsubscribe = onSnapshot(doc(db, "users", userId), (snapshot) => {
console.log(snapshot.data());
});
For a query:
import { collection, onSnapshot, orderBy, query, where } from "firebase/firestore";
const q = query(
collection(db, "messages"),
where("roomId", "==", "general"),
orderBy("createdAt", "desc")
);
const unsubscribe = onSnapshot(q, (snapshot) => {
const messages = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data()
}));
render(messages);
});
The first callback gives an initial snapshot for the current document or query result. After that, the listener receives updates when the matching data changes.
For query listeners, docChanges() gives the difference between snapshots:
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
// A document entered the result set.
}
if (change.type === "modified") {
// A matching document changed.
}
if (change.type === "removed") {
// A document was deleted or no longer matches the query.
}
});
That diffing behavior is already higher-level than a plain WebSocket message. Firestore knows the query, tracks the result set, and tells the client how the result changed.
The Backend Protocol: Listen Targets and Watch Responses
Under the public SDK API, Firestore has a watch/listen protocol.
The official Firestore RPC reference describes a Listen method that accepts ListenRequest messages and returns ListenResponse messages.
A listen request can add or remove a target:
ListenRequest
database
add_target | remove_target
A target can represent:
- a document listen
- a query listen
A listen response can contain:
TargetChangeDocumentChangeDocumentDeleteDocumentRemoveExistenceFilter
Conceptually, the flow looks like this:
Client opens listen stream
Client sends AddTarget(query: messages where roomId == "general")
Server sends TargetChange(ADD)
Server sends DocumentChange(message-1)
Server sends DocumentChange(message-2)
Server sends TargetChange(CURRENT)
Server later sends DocumentChange(message-3)
Client sends RemoveTarget(targetId) when unsubscribing
The important detail is that the client is not subscribing to arbitrary event names like message.created.
It is subscribing to a Firestore target: a document or a query.
That is why Firestore listeners are useful for UI state that maps directly to database state.
How the Web SDK Transports the Stream
In browser applications, Firestore uses the Firebase JavaScript SDK’s network transport. The current public API reference describes the underlying browser transport as WebChannel, with options for long polling in environments where streaming responses are blocked or buffered by proxies, antivirus software, or similar network layers.
That means we should not assume a browser Firestore listener is literally using the WebSocket API.
The SDK owns the transport choice. Our application owns the data model and listener lifecycle.
For example, Firestore has settings such as:
experimentalAutoDetectLongPollingexperimentalForceLongPollingexperimentalLongPollingOptions
Those settings exist because browser streaming behavior can be affected by real network infrastructure. The SDK can use WebChannel behavior and long-polling fallbacks where needed.
On server-side SDKs, snapshot listeners may rely on gRPC streaming. The Firebase Admin Node.js settings mention that onSnapshot() is the operation that requires gRPC when preferRest is otherwise used.
The transport differs by SDK and runtime, but the high-level contract remains the same: listen to documents and queries, receive snapshots and changes.
Local Cache and Latency Compensation
One of the biggest differences between Firestore listeners and raw WebSockets is local state.
Firestore listeners interact with the SDK’s local cache.
When the app writes a document, snapshot listeners can fire immediately before the write reaches the backend. Firebase calls this latency compensation.
Example:
const unsubscribe = onSnapshot(doc(db, "tasks", taskId), (snapshot) => {
const source = snapshot.metadata.hasPendingWrites ? "local" : "server";
console.log(source, snapshot.data());
});
If the user updates a task title, the UI can show the new title immediately. The document metadata tells us whether the snapshot includes local changes that have not been acknowledged by the server yet.
That behavior is not automatic with a WebSocket.
With a custom WebSocket system, we need to design:
- optimistic UI updates
- pending write state
- acknowledgement messages
- rollback behavior
- retry behavior
- conflict handling
Firestore gives us a standard version of that behavior for document data.
Offline Behavior
Firestore also supports offline data access for Android, Apple, and web apps.
When offline persistence is enabled, the SDK caches actively used data. Listeners can receive events from cached data while the device is offline. When the device comes back online, Firestore synchronizes local changes with the backend.
Snapshot metadata helps distinguish cache state:
const unsubscribe = onSnapshot(
q,
{ includeMetadataChanges: true },
(snapshot) => {
const source = snapshot.metadata.fromCache ? "cache" : "server";
console.log(`Snapshot source: ${source}`);
}
);
Two metadata fields matter:
| Metadata | Meaning |
|---|---|
hasPendingWrites | The snapshot includes local writes not yet committed to the backend |
fromCache | The snapshot was served from local cache rather than confirmed current from the server |
With a WebSocket, offline behavior is application-owned. We need to decide how much data to cache, how writes are queued, how reconnects work, and how conflicts are resolved.
Firestore makes these decisions easier when the application’s real-time state is stored in Firestore documents.
What Firestore Handles For Us
Firestore listeners provide a lot more than message delivery.
They handle:
- query matching
- document diffing
- initial snapshots
- local cache reads
- optimistic local writes
- pending write metadata
- cache-vs-server metadata
- stream retries
- resume tokens
- security rules evaluation
- listener unsubscribe behavior
That is the main reason teams choose Firestore listeners. The SDK turns real-time database synchronization into a small amount of application code.
With WebSockets, the platform gives us the connection. The application still needs a protocol.
What WebSockets Give Us Instead
WebSockets are lower-level and more flexible.
A WebSocket connection can carry any application message:
{
"type": "cursor.moved",
"documentId": "doc_123",
"userId": "user_456",
"x": 210,
"y": 480
}
That message does not need to map to a database document. It can represent temporary state, control messages, progress events, multiplayer game updates, terminal output, audio chunks, or anything else the application defines.
This flexibility matters when the real-time data is not naturally a Firestore query result.
WebSockets are often a better fit for:
- high-frequency cursor movement
- multiplayer game loops
- browser terminals
- custom presence systems
- collaborative editing protocols
- server job progress streams
- real-time data from multiple backend systems
- cases where Firestore billing or query shape does not fit the workload
Firestore listeners are excellent when the live state is in Firestore.
WebSockets are better when the live state is an application protocol.
Firestore Listeners vs WebSockets
Here is the practical comparison:
| Area | Firestore Listeners | WebSockets |
|---|---|---|
| Abstraction | Database synchronization | Bidirectional message transport |
| Subscription unit | Document or query | Application-defined channel/event |
| Initial state | Built in | Must be designed |
| Diffs | Built in for query snapshots | Must be designed |
| Local cache | Built into SDK | Must be designed |
| Offline writes | SDK-managed for Firestore writes | Must be designed |
| Security | Firestore Security Rules | Custom auth and authorization |
| Scaling | Managed by Firestore | Application/infrastructure-owned |
| Message shape | Firestore documents and metadata | Any format |
| Best fit | Live database-backed UI | Custom real-time protocol |
The decision is not “which one is more real-time?”
Both can support real-time user experiences.
The better question is:
Is the real-time state primarily Firestore data, or is it an application-specific stream of events?
Cost and Billing Differences
Firestore listeners are convenient, but they are not free.
Firestore billing is tied to document reads, writes, deletes, storage, and related operations. A listener can generate reads when documents are added, updated, removed, or initially loaded into the listener.
That means we should check:
- how many users will keep listeners open
- how many documents match each query
- how often matching documents change
- whether listeners are attached too broadly
- whether the UI repeatedly creates and destroys listeners
- whether the same data is listened to in many components
A chat room with 20 recent messages may be a good listener.
A dashboard that listens to thousands of fast-changing documents may become expensive or noisy.
With WebSockets, costs move somewhere else:
- server instances
- load balancers
- pub/sub infrastructure
- observability
- engineering maintenance
- connection scaling
Firestore reduces operational work, but we still need to model read volume. WebSockets reduce database listener costs for some workloads, but they add infrastructure and protocol ownership.
Security Model
Firestore listeners use Firestore Security Rules.
That is useful because the same authorization model can protect:
- direct reads
- direct writes
- real-time listeners
- query results
If a user is not allowed to read a document, the listener should not receive it.
But rules still need careful design. Query listeners must be compatible with the rules. The client cannot subscribe to a broad query and rely on rules to filter individual results in an arbitrary way. The query itself has to be allowed.
With WebSockets, security is custom.
We need to design:
- connection authentication
- channel authorization
- per-message authorization
- token refresh behavior
- origin checks for browser clients
- rate limits
- abuse handling
Firestore gives us a managed security layer for Firestore data. WebSockets give us full control, which also means full responsibility.
Ordering and Consistency
Firestore listeners provide snapshots of document/query state. The application receives document changes as the backend and local cache converge on a consistent view.
That is different from a raw event stream.
For example, a Firestore query listener is good for:
Show the latest 50 messages in this room ordered by createdAt.
It is less ideal for:
Deliver every transient typing event in order.
Typing indicators do not need durable database writes for every keystroke. A WebSocket or another ephemeral real-time channel is often cleaner.
Firestore stores state. WebSockets move messages.
That distinction matters when designing the system.
Reconnects and Resume Behavior
Both approaches need to survive network failure.
Firestore listeners handle much of this through the SDK. The watch protocol supports resume tokens, and the SDK can reconnect and resume listening where possible. The application mostly sees snapshots and metadata changes.
With WebSockets, reconnect behavior is application code:
- detect disconnect
- reconnect with backoff
- authenticate again
- resubscribe to channels
- fetch missed state
- deduplicate messages
- repair local UI state
That is not a reason to avoid WebSockets. It is a reason to budget for the protocol work.
If the product needs custom real-time behavior, that work may be worth it. If the product only needs “keep this Firestore query live,” the Firestore listener already solves most of it.
Performance Considerations
Before choosing Firestore listeners, we should check the query shape.
Good listener patterns:
- listen to one document
- listen to a small bounded query
- listen to recent items with
limit - unsubscribe when the UI no longer needs data
- keep listener ownership centralized
- use
docChanges()to update local UI incrementally
Risky listener patterns:
- listening to a whole large collection
- creating listeners inside repeated child components
- attaching listeners without cleanup
- listening to high-churn documents that change many times per second
- using listeners for ephemeral events that do not need persistence
Before choosing WebSockets, we should check the operational side:
- connection count
- fan-out requirements
- message rate
- backpressure behavior
- auth refresh
- replay or missed-message recovery
- server deploy and drain behavior
- observability for connection health
Neither option removes system design. They move the complexity to different places.
When Firestore Listeners Are the Better Fit
Firestore listeners are usually the better fit when:
- the source of truth is already Firestore
- the UI needs live document or query state
- offline behavior is useful
- optimistic local writes improve UX
- Security Rules are the right authorization layer
- the listener result set is bounded and predictable
- the team wants less real-time infrastructure to operate
Examples:
- user profile updates
- task lists
- chat room messages with reasonable limits
- collaborative app metadata
- notification lists
- order status pages
- admin screens over Firestore collections
The key is that the UI wants current database state, not an arbitrary stream of events.
When WebSockets Are the Better Fit
WebSockets are usually the better fit when:
- messages are not naturally Firestore documents
- events are high-frequency or ephemeral
- the server must stream output from non-Firestore systems
- the protocol needs custom acknowledgement or ordering
- multiple data sources feed the same real-time channel
- Firestore read costs would be too high for the update rate
- the app needs custom presence, cursor, game, or terminal behavior
Examples:
- live cursor positions
- typing indicators
- multiplayer game state
- terminal sessions
- collaborative text operations
- live audio or binary streams
- infrastructure logs
- long-running job progress from worker systems
The key is that the product needs an application protocol, not only a database listener.
A Hybrid Architecture Is Common
Many real-time applications use both.
For example, a chat product might use:
- Firestore for durable room messages
- Firestore listeners for recent message history
- WebSockets for typing indicators
- WebSockets or another presence system for online status
- Cloud Functions or backend workers for moderation and notifications
That split works because durable state and ephemeral state have different requirements.
Durable messages should be stored, queryable, secured, and recoverable.
Typing indicators should be fast and temporary. They do not need to become permanent database writes.
The mistake is forcing every real-time signal into one technology.
What We Should Check Before Choosing
Before choosing Firestore listeners, check:
- Does the UI need live Firestore document/query state?
- Can the query be bounded with filters, ordering, and limits?
- How many documents are loaded initially?
- How often do matching documents change?
- Will Security Rules allow the query cleanly?
- Is offline cache behavior useful or risky for this data?
- Can the application handle
fromCacheandhasPendingWritescorrectly? - What is the expected read cost at real usage?
Before choosing WebSockets, check:
- What exact message protocol is needed?
- How will clients authenticate and refresh auth?
- How will channels or rooms be authorized?
- What happens after reconnect?
- How will missed messages be recovered?
- How will slow clients and backpressure be handled?
- How will the system scale across server instances?
- What metrics and logs are needed to operate it?
If the answers mostly describe documents, queries, cache, and rules, Firestore listeners are probably the better starting point.
If the answers mostly describe events, channels, ordering, backpressure, and custom protocol rules, WebSockets are probably the better fit.
Key Takeaways
Firestore listeners and WebSockets both support real-time experiences, but they solve different problems.
Firestore listeners are a managed synchronization layer for Firestore documents and queries. They provide initial snapshots, document diffs, local cache behavior, latency compensation, metadata, retries, and Security Rules integration.
WebSockets are a lower-level bidirectional transport. They are more flexible, but they require us to design the application protocol, reconnect behavior, authorization, scaling, backpressure, and observability.
The practical rule is:
- use Firestore listeners when the UI needs live Firestore state
- use WebSockets when the product needs a custom real-time event protocol
- use both when durable state and ephemeral events have different requirements
That distinction keeps the architecture simpler and avoids using Firestore as a message bus or WebSockets as a database synchronization layer.