Let's explore how the Firebase Realtime Database's WebSocket API works under the hood.
Firebase offers a powerful Realtime Database accessible through a WebSocket connection. While the official libraries provide a user-friendly interface, understanding the communication protocol can be valuable for debugging and building custom tools.
The Firebase WebSocket protocol isn't officially documented, so while this information is accurate as of Firebase version 10.12, things could change down the road. Be aware that future Firebase updates might affect the specifics of the message formats and behaviors.
To establish a connection, we'll use your Firebase project's secure WebSocket URL. This usually looks like this:
const url =
'wss://<project-id>.firebaseio.com/.ws?v=5&p=<application-id>&ns=<project-id>';
<project-id>
: Your unique Firebase project identifier.<application-id>
: Your Firebase application ID.To create the WebSocket connection, you can use the WebSocket
constructor:
const ws = new WebSocket(url);
Before sending messages, we need to ensure a successful connection. You can achieve this by listening for the open
event on the ws
object:
ws.onopen = function () {
console.log('WebSocket connection established!');
// Now it's safe to send messages over the WebSocket connection
};
Communication between client and server utilizes JSON-encoded messages. The initial message from the server relays the server's current time, API version, and hostname. The official SDK retrieves the new hostname from the server's initial message and stores it for future use in localStorage under the key firebase:host:<host>
, where <host>
is the received hostname. This ensures subsequent connections can use the correct hostname.
{
"t": "c",
"d": {
"t": "h",
"d": { "ts": 1717335753015, "v": "5", "h": "<hostname>" }
}
}
Most messages follow a similar structure: a "t" property indicating the message type and a "d" property containing the data.
To ensure a persistent connection, the client sends a special "0" message to the server at regular intervals. This message, triggered 45 seconds after the last WebSocket message is sent or received, keeps the connection alive and notifies the server of ongoing activity.
Public data can potentially be accessed without authentication, depending on how you've configured your Firebase security rules. To interact with private data, you'll need to authenticate. This involves obtaining an ID token from Google's Secure Token API (details in the Firebase documentation). Once you have the token, send it to the API to authenticate subsequent messages.
Here's an example of sending an authentication message:
ws.send(
JSON.stringify({
t: 'd', // Message type
d: {
r: msgId++, // Unique message ID (starts at 1)
a: 'auth', // Action: authentication
b: { cred: idToken }, // Authentication data (ID token)
},
}),
);
This format applies to most messages you send to the server: { "t": "d", "d": { "r": msgId, ... } }
, with msgId
being a unique identifier incremented for each message.
To receive updates for a specific data path, the client sends a subscribe message to the server. It's recommended to store the received data locally in a cache to minimize unnecessary network traffic. Here's an example of a subscribe message:
{ "t": "d", "d": { "r": 3, "a": "q", "b": { "p": "/path/to/data", "h": "" } } }
Receiving Data and Confirmation:
Once subscribed, the server responds with two messages:
Data Message: This message contains the data for the subscribed path, including:
Status Message: This message confirms the subscription was successful. It includes:
Example message:
{"t":"d","d":{"b":{"p":"path/to/data","d":"path-data"},"a":"d"}}
{"t":"d","d":{"r":3,"b":{"s":"ok","d":{}}}}
The unsubscribe message follows a similar structure to the subscribe message, but with the action set to "n". By sending this message, the client informs the server to stop sending updates for the specified path. Here's an example:
{ "t": "d", "d": { "r": 4, "a": "n", "b": { "p": "/path/to/data" } } }
orderByChild()
Firebase performs client-side ordering when querying entire collections. When combined with other query parameters, orderByChild
corresponds to the "i" field in the protocol.
query(ref(db, 'path/to/data'), limitToFirst(2), orderByChild('name'));
Corresponding JSON:
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": { "l": 2, "vf": "l", "i": "name" },
"t": 1,
"h": ""
}
}
}
limitToLast
and "l" indicates limitToFirst
.When the .indexOn
rule is not set in the security rules, Firebase sends the entire matching dataset to the client instead of applying restrictions server-side.
orderByKey()
and orderByValue()
These methods use a similar JSON structure to orderByChild()
, with the "i" field set to ".key"
or ".value"
respectively.
limitToFirst()
and limitToLast()
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": {
"l": 1,
"vf": "l"
},
"t": 1,
"h": ""
}
}
}
limitToLast
and "l" indicates limitToFirst
.startAt()
The startAt()
argument type depends on which orderBy() function was used in this query. Specify a value that matches the orderBy() type. When used in combination with orderByKey(), the value must be a string.
query(ref(db, '/path/to/data'), startAt('Second Item'), orderByValue());
corresponds to:
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": {
"sp": "Second Item",
"sin": true,
"i": ".value"
},
"t": 1,
"h": ""
}
}
}
startAt()
value.startAt
).startAfter()
Similar to startAt()
, but with "sin" set to false and "sn" set to "[MAX_NAME]":
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": {
"sp": "Second Item",
"sn": "[MAX_NAME]",
"sin": false,
"i": ".value"
},
"t": 1,
"h": ""
}
}
}
sp
value "Second Item" corresponds to startAfter('Second Item')
.sin
value false
indicates that the startAt
value should be excluded, which aligns with startAfter
.endAt()
and endBefore()
These methods use similar structures to startAt()
and startAfter()
, but with "ep"
(end point), and "ein"
(end inclusive) fields. The "en"
field is set to "[MIN_NAME]"
query(ref(db, 'path/to/data'), endAt('Second Item'), orderByValue());
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": { "ep": "Second Item", "ein": true, "i": ".value" },
"t": 1,
"h": ""
}
}
}
ep
(end point) value "Second Item" corresponds to endAt('Second Item')
.ein
(end inclusive) in the JSON with value true
indicates that the endAt
value should be included in the results.query(ref(db, 'fb/test/collection'), endBefore('Second Item'), orderByValue());
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/fb/test/collection",
"q": {
"ep": "Second Item",
"en": "[MIN_NAME]",
"ein": false,
"i": ".value"
},
"t": 1,
"h": ""
}
}
}
ep
(end point) value "Second Item" corresponds to endBefore('Second Item')
.ein
(end inclusive) in the JSON with value false
indicates that the endAt
value should be excluded, which aligns with endBefore
.equalTo()
The equalTo()
method combines startAt()
and endAt()
with identical values:
query(ref(db, 'path/to/data'), equalTo('Second Item'), orderByValue());
{
"t": "d",
"d": {
"r": 2,
"a": "q",
"b": {
"p": "/path/to/data",
"q": {
"sp": "Second Item",
"sin": true,
"ep": "Second Item",
"ein": true,
"i": ".value"
},
"t": 1,
"h": ""
}
}
}
set()
set(refB, Math.random());
{
"t": "d",
"d": {
"r": 2,
"a": "p",
"b": { "p": "/path/to/data", "d": 0.24184493823740127 }
}
}
a
(action) value "p" corresponds to "put".b.p
(path)b.d
(data) in the JSON with the numeric value corresponds to the randomly generated value in Math.random()
.push()
Similar to set()
, but generates a new unique key on the client-side.
push(refB, { name: 'push-test', value: Math.random() });
{
"t": "d",
"d": {
"r": 2,
"a": "p",
"b": {
"p": "/path/to/data/-O1c1OTqd-img9zzEvLA",
"d": { "name": "push-test", "value": 0.943367049653683 }
}
}
}
update()
update(refB, { '-O1c1OTqd-img9zzEvLA': { value: Math.random() } });
{"t":"d","d":{"r":2,"a":"m","b":{"p":"/path/to/data","d":{"-O1c1OTqd-img9zzEvLA":{"value":0.626719018744212}}}}}}
a
(action) with value "m" corresponds to a "merge" operation.b.p
(path) indicates the target location for the update.b.d
(data) contains key-value pairs representing the fields to update, where keys can be deep paths relative to the base path.remove()
Deletion is performed using a "put" operation with a null
value:
remove(refB);
JSON:
{
"t": "d",
"d": { "r": 2, "a": "p", "b": { "p": "/fb/test/variable", "d": null } }
}
a
indicates a "put" operation.b.d
with a null
value indicates that the data should be removed.Transactions are similar to "put" operations but include a hash (possibly the base64 SHA1 of the current data):
runTransaction(refB, post => {
post.value = Math.random();
return post;
});
{
"t": "d",
"d": {
"r": 4,
"a": "p",
"b": {
"p": "/path/to/data",
"d": { "name": "test", "value": 0.9155112294078509 },
"h": "7deOIp56v5nShyYq231WnG/QGgQ="
}
}
}
Firebase supports atomic increments using a special server value (".sv"
):
update(refB, {
value: increment(1),
});
{
"t": "d",
"d": {
"r": 2,
"a": "m",
"b": {
"p": "/fb/test/variable",
"d": { "value": { ".sv": { "increment": 1 } } }
}
}
}
b.d
(data) contains a nested object with a special key .sv
and a value of increment: 1
. This structure indicates an atomic increment operation for the "value" field.