Firebase RTDB stores data as a single JSON tree with path-based access — no tables, no SQL, no schema enforcement
Real-time sync pushes changes to all connected clients in ~50-100ms without polling or WebSocket management on your end
Security rules ARE your backend — no server code sits between client and database by default, which means rules are not optional
Keep data flat: nesting kills performance because Firebase downloads entire subtrees on every read, not just the fields you want
Multi-path updates write to multiple nodes atomically in one network round-trip — use them instead of sequential set() calls
onDisconnect handlers execute server-side even during hard crashes — they're the foundation of every reliable presence system built on RTDB
Plain-English First
Imagine a giant shared whiteboard in the cloud. Every person in the room can see it at the same time, and the moment someone writes something new, everyone else's view updates instantly — no refreshing, no waiting. Firebase Realtime Database is exactly that whiteboard for your app's data. Instead of sending a letter, which is what a normal HTTP request looks like — you write it, send it, wait for a response — your app just watches the whiteboard and reacts the instant anything changes. The catch is that the whiteboard is organised like one big nested filing system, and if you file things in the wrong drawers, you end up pulling out way more paper than you needed just to find one document. That's the structural discipline that makes or breaks a Firebase app.
Most apps eventually hit the same wall: users do something, the server has to know, other users have to find out, and the whole thing needs to feel instant. Chat apps, live scoreboards, collaborative editing tools, ride-tracking screens — they all need data that moves in real time. The traditional request-response cycle of REST APIs starts feeling like sending telegrams when you need a phone call. That's the gap Firebase Realtime Database was built to close.
Firebase Realtime Database is a cloud-hosted NoSQL database that stores data as one big JSON tree and pushes every change to every connected client in milliseconds — without the client ever asking. It removes the entire layer of polling logic, WebSocket boilerplate, connection management, and server infrastructure that you'd otherwise write yourself. You get persistent connections, automatic reconnection, offline write queuing, and conflict handling out of the box.
The trade-off is that you're working with a flat-ish JSON structure rather than relational tables, and how you shape your data becomes the most critical architectural decision you'll make. A poorly structured data tree will make your app feel slow and run up a surprisingly large Firebase bill. A well-structured one reads like a document that the database was designed to serve.
By the end of this article you'll understand why the JSON tree structure exists and how to model data for it correctly, how to read and write data both once and in real time without leaking memory, how security rules act as your server-side gatekeeping and validation layer, and which mistakes will silently kill performance before you launch. The patterns here come from real production systems — not toy examples.
How Firebase Structures Data — The JSON Tree You'll Live Inside
Every piece of data in Firebase Realtime Database lives at a path inside one enormous JSON object. There are no tables, no rows, no foreign keys, no schema enforcement. Think of it as a deeply nested JavaScript object that every connected client shares simultaneously, with every change propagated to everyone watching.
Each node in the tree has a key — like a folder name in a file system — and either a scalar value (string, number, boolean, null) or more children. Firebase assigns auto-generated push keys when you add items to a list using push(). These keys look like -NxK8mQ2r7... — that long hyphenated format is not arbitrary. The prefix encodes a timestamp, which means push keys sort chronologically by default. That's genuinely useful for chat histories and activity feeds: the most recent item always has the lexicographically highest key.
The single most important rule in Firebase data modelling is: keep it flat. Beginners nest everything — messages inside rooms inside users, orders inside customers inside regions. The problem is that Firebase sends you the entire subtree when you read any node. Nest too deep and reading one user's profile drags down their entire message history, their order archive, their notification log. You asked for a name and got a megabyte.
The fix is to store references — IDs — instead of embedding full objects, exactly the way foreign keys work in a relational database. The difference is that Firebase doesn't enforce referential integrity. That's your job. The payoff is that each node stays small and readable independently.
The path-based model also means every node is independently addressable via its URL: https://your-app-default-rtdb.firebaseio.com/chats/room42/messages is a real, directly accessible piece of data. That makes access control intuitive: you can lock /users/private while leaving /products publicly readable, and the path structure communicates the intent to anyone reading the security rules.
RealtimeDatabaseStructure.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// This is what your FirebaseRealtimeDatabase actually looks like in the console.
// The critical pattern: users, posts, and postLikes are siblings at the top level.
// No nesting across different entity types.
// Users store post IDs, not post objects — reading a profile stays small.
{
"users": {
"uid_alice": {
"displayName": "Alice Mbeki",
"email": "alice@example.com",
"joinedAt": 1718000000000,
// Only the post KEY is stored here — not the post content.
// Reading uid_alice's profile returns ~120 bytes.
// If full post objects were embedded here, the same read could return megabytes.
"postIds": {
"-NxK8mQpostA": true,
"-NxK8mQpostB": true
}
},
"uid_bob": {
"displayName": "Bob Okafor",
"email": "bob@example.com",
"joinedAt": 1718003600000,
"postIds": {
"-NxK8mQpostC": true
}
}
},
"posts": {
// Firebasepush() generates this key — time-ordered, so latest is always last
// when sorted lexicographically. This makes limitToLast() pagination trivial.
"-NxK8mQpostA": {
"authorId": "uid_alice", // Reference to /users/uid_alice — not the object itself
"title": "Getting started with Firebase",
"body": "Here is what I learned on day one...",
"publishedAt": 1718000100000,
"likesCount": 12 // Denormalised counter — updated via transaction
},
"-NxK8mQpostB": {
"authorId": "uid_alice",
"title": "Firebase security rules deep dive",
"body": "Rules are not just permissions, they are your entire server layer...",
"publishedAt": 1718050000000,
"likesCount": 47
},
"-NxK8mQpostC": {
"authorId": "uid_bob",
"title": "Structuring realtime data for scale",
"body": "Flat is almost always better than nested when working with RTDB...",
"publishedAt": 1718060000000,
"likesCount": 8
}
},
// postLikes lives separately so toggling a like does not rewrite the full post object.
// This node has its own read pattern: 'did this user like this post?'
// Separating it means that read stays at the exact path you need.
"postLikes": {
"-NxK8mQpostA": {
"uid_bob": true,
"uid_carol": true
},
"-NxK8mQpostB": {
"uid_bob": true
}
},
// Presence is its own top-level node — completely different access pattern
// from user profiles. Online/offline status changes frequently and needs
// its own security rules (any authenticated user can write their own presence).
"presence": {
"uid_alice": {
"displayName": "Alice Mbeki",
"onlineSince": 1718070000000,
"status": "online"
}
}
}
Output
// No runtime output — this is the data shape visible in the Firebase Console
// If posts were nested inside that node, the same read could return 50MB+
// for an active user with months of posting history.
//
// Reading /posts returns all posts — usually not what you want.
// Reading /posts?orderBy="authorId"&equalTo="uid_alice" returns only Alice's posts
// using Firebase's server-side filtering.
Watch Out: Nesting Kills Performance at Scale
If you nest posts inside users in Firebase, every read of /users — including reads that only need a display name — downloads ALL posts for ALL users. Firebase has no concept of column-level selection. The subtree you read is the subtree you pay for and wait for. Always model by access pattern: if two types of data are read separately, store them separately. The rule is simple — if you'll ever need the child data independently of the parent, it belongs at its own top-level path.
Production Insight
Firebase downloads the ENTIRE subtree when you read a node — there is no field-level selection like SQL columns.
Reading /users with nested posts can pull megabytes for active users, and every change anywhere in that subtree triggers a full re-download for all attached listeners.
Rule: if two data types will ever be read independently, store them as top-level siblings and link via reference IDs. This is the most important structural decision in any Firebase project.
Key Takeaway
Flat references beat nested objects — store IDs, not copies of full objects.
Every node is independently addressable via its path, which makes access control and independent reads both natural.
Data modelling is the single most critical architectural decision in RTDB — a wrong data shape cannot be partially fixed with clever queries.
Reading and Writing Data — One-Time Reads vs. Live Listeners
Firebase gives you two fundamentally different ways to get data: ask once and walk away, or subscribe and keep watching until you explicitly stop. Choosing wrong is one of the most common sources of both subtle bugs and unexpectedly high billing charges, so the distinction deserves careful attention.
get() performs a single read. It returns the current data as a snapshot, and the connection for that specific read closes after the response arrives. Use get() when the data won't change during the user's current interaction, or when you only need a snapshot to make a decision — checking if a username is already taken during signup, prefetching a user profile immediately after authentication, or loading configuration data that changes rarely.
onValue() attaches a persistent listener that fires immediately with the current data and then again every single time that node changes anywhere in the database. This is Firebase's defining feature — the 'real-time' in Realtime Database. But it comes with a responsibility that tutorials often skim past: you must call the returned unsubscribe function when the component unmounts or the user navigates away. Every active listener consumes bandwidth and triggers client-side processing. Forget to remove it and you've created a memory leak that compounds over every navigation event for the rest of the user's session.
Firebase also offers child-level listeners that are essential for list data: onChildAdded, onChildChanged, onChildRemoved, and onChildMoved. For a chat feed, you almost always want onChildAdded rather than onValue. The difference is significant: onValue fires with the entire current list every time any single message changes. onChildAdded fires once per existing message when first attached (letting you build the initial list incrementally), then once per new message — never re-processing old messages. That distinction turns an O(n) re-render on every new message into an O(1) append.
Writes follow similar choices:set() overwrites the entire node at a path (which will delete sibling fields if you're not careful), update() modifies only the fields you name and leaves everything else alone, push() creates a new child with an auto-generated time-ordered key, and the multi-path update pattern using the root ref handles writing to multiple unrelated paths atomically in one round-trip.
FirebaseReadWritePatterns.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { initializeApp } from'firebase/app';
import {
getDatabase,
ref,
get,
set,
push,
update,
onValue,
onChildAdded,
serverTimestamp
} from'firebase/database';
// ── SETUP ──────────────────────────────────────────────────────────────────const firebaseApp = initializeApp({
apiKey: 'YOUR_API_KEY',
authDomain: 'your-app.firebaseapp.com',
databaseURL: 'https://your-app-default-rtdb.firebaseio.com',
projectId: 'your-app'
});
const database = getDatabase(firebaseApp);
// ── ONE-TIME READ ───────────────────────────────────────────────────────────// get() is the right tool when you need data once.// Good for: profile prefetch on login, username availability check, config load.// The connection for this specific read closes after the response — no listener attached.asyncfunctionloadUserProfile(userId) {
const userRef = ref(database, `users/${userId}`);
const snapshot = awaitget(userRef);
if (!snapshot.exists()) {
console.log('No profile found for user:', userId);
returnnull;
}
// .val() unpacks the DataSnapshot into a plain JS object.const profile = snapshot.val();
console.log('Loaded profile:', profile);
return profile;
}
// ── WRITE: set() OVERWRITES — use it for known keys ─────────────────────────// set() replaces the ENTIRE node at the path.// Any fields at that path not included in the new value are DELETED.// Use it when you own the full shape of the node — like creating a user profile.asyncfunctioncreateUserProfile(userId, displayName, email) {
const userRef = ref(database, `users/${userId}`);
awaitset(userRef, {
displayName,
email,
joinedAt: Date.now(),
postIds: {} // start with an empty post index
});
console.log(`Profile created for ${displayName}`);
}
// ── MULTI-PATH WRITE: Atomic updates across separate nodes ──────────────────// This is how you write to two unrelated paths in one network round-trip.// Both writes land together — if the user goes offline between two sequential// set() calls, you'd get a partial state. Multi-path updates prevent that.asyncfunctionpublishPost(authorId, title, body) {
const postsRef = ref(database, 'posts');
// push() generates a unique time-ordered key BEFORE the write completes.// You get the key immediately to use in the multi-path update.const newPostRef = push(postsRef);
const newPostKey = newPostRef.key; // e.g. "-NxK8mQpostD"// Build the multi-path update object.// Keys are paths relative to root, values are what to write there.const updates = {};
updates[`posts/${newPostKey}`] = {
authorId,
title,
body,
publishedAt: Date.now(),
likesCount: 0
};
// Also record the post ID under the author's profile.// Both writes happen atomically in one round-trip.
updates[`users/${authorId}/postIds/${newPostKey}`] = true;
// Pass updates to the ROOT ref — this is what makes multi-path work.awaitupdate(ref(database), updates);
console.log('Post published with key:', newPostKey);
return newPostKey;
}
// ── LIVE LISTENER: onChildAdded for a growing list ──────────────────────────// Use this pattern for: chat messages, activity feeds, notification lists.// Fires once per existing item on initial attach, then once per new addition.// NEVER re-downloads old items when a new one arrives — critical for performance.functionsubscribeToChatMessages(roomId, onNewMessage) {
const messagesRef = ref(database, `chats/${roomId}/messages`);
// The return value IS the unsubscribe function.// Store it. Call it when the component unmounts. This is not optional.const unsubscribe = onChildAdded(messagesRef, (snapshot) => {
const message = snapshot.val();
const messageKey = snapshot.key;
console.log(`[${messageKey}] ${message.senderName}: ${message.text}`);
onNewMessage({ key: messageKey, ...message });
});
// Return the cleanup function to the caller.return unsubscribe;
}
// ── LIVE LISTENER: onValue for a single value or small node ────────────────// Use for: live counters, status indicators, feature flags, small config objects.// Not for lists — every change re-delivers the entire node.functionwatchOnlineUserCount(onCountChange) {
const onlineCountRef = ref(database, 'meta/onlineUserCount');
const unsubscribe = onValue(onlineCountRef, (snapshot) => {
const count = snapshot.val() ?? 0;
console.log('Online users right now:', count);
onCountChange(count);
});
return unsubscribe;
}
// ── CLEANUP PATTERN (React hooks) ──────────────────────────────────────────// This is the correct lifecycle for any Firebase listener in a React component.//// useEffect(() => {// const stopListening = subscribeToChatMessages(roomId, appendMessage);// return () => stopListening(); // Runs on unmount OR when roomId changes// }, [roomId]);//// Missing the return cleanup causes listeners to stack up across navigations.// If the user visits 10 different chat rooms, you end up with 10 active listeners// on 9 rooms they've already left — each one consuming bandwidth and triggering re-renders.// ── DELETE via multi-path null write ─────────────────────────────────────────// Setting a path to null is how you delete in Firebase.// Using multi-path means all three deletions happen together.asyncfunctiondeletePost(postKey, authorId) {
const updates = {};
updates[`posts/${postKey}`] = null; // delete the post
updates[`users/${authorId}/postIds/${postKey}`] = null; // clean up the index entry
updates[`postLikes/${postKey}`] = null; // remove orphaned likes dataawaitupdate(ref(database), updates);
console.log('Post and all references deleted:', postKey);
}
// After publishPost('uid_alice', 'My new post', 'Body here...'):
Post published with key: -NxK8mQpostD
// subscribeToChatMessages fires for 2 existing messages on initial attach,
// then fires again automatically when Carol sends a new message:
[-NxChat001] Alice Mbeki: Hey everyone!
[-NxChat002] Bob Okafor: Welcome to the channel!
[-NxChat003] Carol Smith: Just joined — hello! ← new message, automatic push
// watchOnlineUserCount fires once immediately, then each time the count changes:
Online users right now: 143
Online users right now: 144 ← fired automatically as a new user connected
Pro Tip: Multi-Path Updates Are Your Best Friend for Consistency
Every time you need to keep two nodes in sync — like a post and its ID reference in the author's profile — use a single update(ref(database), { path1: value1, path2: value2 }) call from the root ref rather than two sequential set() calls. Firebase applies multi-path updates atomically in one network round-trip. If the user goes offline between two separate set() calls, you get a partial state that is often worse than no write at all. Multi-path updates eliminate that class of bug entirely.
Production Insight
Forgetting to call the unsubscribe function creates listeners that persist for the entire session, not just the component lifecycle.
In a single-page app where users navigate between views, orphaned listeners compound — by the time a user has visited 10 chat rooms, you can have 9 active listeners on rooms they're no longer looking at, each one triggering re-renders and consuming bandwidth.
Rule: treat the unsubscribe function as part of the listener's definition, not as an afterthought. If you write onChildAdded, the cleanup call goes in the same breath.
Key Takeaway
get() for one-time reads, onValue for live single values, onChildAdded for live lists — choosing wrong is the leading cause of both billing spikes and memory leaks.
Always store the unsubscribe function and call it on component unmount — this is not optional and forgetting it is the most common Firebase memory leak pattern.
Multi-path updates from the root ref are the correct tool for keeping multiple nodes in sync — they're atomic, one round-trip, and eliminate partial-state bugs.
Security Rules — Your Serverless Gatekeeper
Firebase Security Rules are the most underestimated — and most misunderstood — part of the platform. They're not a nice-to-have access control layer on top of your backend. They ARE your backend validation layer. When a client reads or writes to Firebase, there is no server-side application code standing between the request and the database unless you wrote rules that enforce it. Anyone who has your project's database URL can attempt reads and writes directly. Rules are what stop them.
Rules are written in a JSON-like syntax and evaluated at the path level. Each path can declare .read, .write, and .validate conditions using built-in variables: auth contains the requesting user's authentication token, data is the current value at the path, newData is what the value would become if the write proceeded, now is the server-side timestamp, and root lets you reference other paths in the database from within a rule.
The behaviour that trips people up most consistently is cascade: if a .read or .write rule grants access at a parent path, that access applies to every child beneath it. You cannot revoke access at a child level once a parent has opened it. This means your tree structure and your access control model must be designed together, not separately. Sensitive data must live at paths that you can lock independently — it cannot share a parent node with public data.
The .validate rule is separate from .read and .write and is your data integrity layer. Even if a write is permitted, it won't proceed unless every .validate rule in the write path passes. This is where you enforce that display names are strings of a reasonable length, that timestamps are numbers and not strings, that required fields are present, and that values stay within acceptable ranges. It replicates what you'd normally do in middleware, without running any server.
One operational reality: rules that deny access do not throw exceptions or return error HTTP status codes in the way a REST API would. They resolve with a permission_denied error code in your callback. If you're not handling that callback explicitly, writes fail silently from the user's perspective. The Firebase Console Rules Playground is essential — test every rule path with the exact auth context you expect before deploying.
database.rules.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// Deploy with: firebase deploy --only database
// Or paste into FirebaseConsole > RealtimeDatabase > Rules tab
// Test every change in the RulesPlayground before deploying to production
{
"rules": {
// ── PUBLICREAD, ADMIN-ONLYWRITE ─────────────────────────────────────
"products": {
// Unauthenticatedvisitors (product catalogue browsers) can read.
".read": true,
// Only authenticated users whose uid appears in /admins can write.
// We store admin status as /admins/{uid}: true — a simple boolean flag.
// root.child() lets you cross-reference another path from within a rule.
".write": "auth !== null && root.child('admins').child(auth.uid).val() === true"
},
// ── OWNER-ONLYWRITEWITHDATAVALIDATION ─────────────────────────────
"users": {
"$userId": {
// $userId is a wildcard — it matches any key under /users/
// and its value is available in the rule expression.
// Any logged-in user can read profiles — e.g. to show names in a chat.
".read": "auth !== null",
// Only the profile's owner can write to it.
".write": "auth !== null && auth.uid === $userId",
// Structural validation — these fields must exist after the write.
".validate": "newData.hasChildren(['displayName', 'email', 'joinedAt'])",
"displayName": {
// Must be a non-empty string, max 50 characters.
".validate": "newData.isString() && newData.val().length >= 2 && newData.val().length <= 50"
},
"email": {
// Must be a string — FirebaseAuth already validated the format before creating the account.
".validate": "newData.isString() && newData.val().length > 0"
},
"joinedAt": {
// Must be a number (Unix ms timestamp).
// !data.exists() prevents modification after creation — joinedAt is immutable.
".validate": "newData.isNumber() && !data.exists()"
},
"postIds": {
// Allow any valid post ID mapping — keys are post IDs, values are booleans.
"$postId": {
".validate": "newData.isBoolean()"
}
}
}
},
// ── POSTS — AUTHENTICATEDCREATE, OWNEREDIT/DELETE ───────────────────
"posts": {
// Any logged-in user can read all posts.
".read": "auth !== null",
"$postId": {
// Two conditions, depending on whether the post exists:
// Creating (data does not exist): authorId in new data must match the writer's uid.
// Editing/deleting (data exists): the existing authorId must match the writer's uid.
// This prevents both impersonation on create and editing someone else's post.
".write": "auth !== null && (
!data.exists() && newData.child('authorId').val() === auth.uid ||
data.exists() && data.child('authorId').val() === auth.uid
)",
".validate": "newData.hasChildren(['authorId', 'title', 'body', 'publishedAt'])",
"title": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 200"
},
"body": {
".validate": "newData.isString() && newData.val().length > 0"
},
"publishedAt": {
// Must be a number and cannot be changed after creation.
".validate": "newData.isNumber() && !data.exists()"
},
"likesCount": {
// Must be a non-negative integer — prevents someone writing -1000 to destroy the count.
".validate": "newData.isNumber() && newData.val() >= 0 && newData.val() % 1 === 0"
}
}
},
// ── PRESENCE — USERCANWRITEONLYTHEIROWNPRESENCE ─────────────────
"presence": {
// Any logged-in user can read the full presence map (to show who's online).
".read": "auth !== null",
"$userId": {
// Each user can only write their own presence node.
".write": "auth !== null && auth.uid === $userId"
}
},
// ── DENYALLUNSPECIFIEDPATHS ─────────────────────────────────────────
// Firebase's default is already to deny. Being explicit here documents intent
// and protects against accidental new top-level nodes having no rules.
"$other": {
".read": false,
".write": false
}
}
}
Output
// Firebase Console Rules Playground output:
// Test: unauthenticated write to /users/uid_alice
Simulation result: DENIED
Reason: .write on /users/$userId requires auth !== null — request has no auth token
// Test: uid_bob tries to write to /users/uid_alice
Reason: .validate on /posts/$postId/title — val().length (250) > 200
Watch Out: Rules Cascade Downward and Cannot Be Revoked by Children
If you set .read: true on /posts, every node beneath /posts is readable by anyone — including /posts/secretDraftPost if it happens to exist there. You cannot add .read: false on a child path to override what a parent already granted. This is the single most common security misunderstanding in Firebase. Design your tree so that sensitive data lives at its own top-level path that you can lock independently. Never mix data with different access requirements under the same parent node.
Production Insight
Rules that deny access do not surface as exceptions or HTTP errors — they call your error callback with a permission_denied code. If you are not handling that callback, failures are silent from the user's perspective.
Test-mode rules (.read: true, .write: true at the root) expose your entire database to anyone on the internet with your project ID. Firebase warns you in the console but will not block you from deploying them.
Rule: test every path change in the Rules Playground before deploying. Simulate both allowed and denied cases — a rule that passes everything you test for may still be missing a case you didn't think to test.
Key Takeaway
Security rules ARE your backend — there is no application server intercepting reads and writes unless you wrote rules to enforce constraints.
Rules cascade downward without exception: access granted at a parent cannot be revoked at a child. Design your tree with access patterns in mind.
Use .validate to enforce data shape, value ranges, and field immutability — it prevents malformed or malicious writes even when the caller is authenticated.
Real-World Patterns — Transactions, Offline Support, and When NOT to Use Firebase RTDB
Three production realities that documentation glosses over deserve direct attention.
Transactions for contested writes: runTransaction() is how you safely increment a counter or claim a limited-quantity item when multiple clients might be writing simultaneously. Without it, two users liking a post at the same instant can both read likesCount: 10, both compute 11, and both write 11 — leaving you with 11 instead of 12. A transaction reads the current value from the server, runs your update function with that value, and writes the result atomically. If another client modified the same path between your read and write, Firebase detects the conflict, re-reads the new value, and re-runs your function. This retry loop continues until a clean write succeeds. No race condition is possible because Firebase serialises conflicting transactions on the server.
Offline support is built in by default, which is both a feature and something you need to understand. The Firebase SDK caches data locally and queues write operations when the device is offline. When connectivity returns, queued writes replay automatically. Your listeners fire immediately with locally cached data — before any server response arrives — which is excellent for perceived performance on slow connections. But it means you must never treat the first emission from onValue as confirmed server data for anything that has real-world consequences. A payment confirmation, an inventory reservation, a slot booking — these must wait for the write's returned promise to resolve before showing success UI.
When to choose Firestore instead: Firebase Realtime Database has limited querying. You can filter by one property, order by one property, and combine those — but you cannot write compound queries that filter on multiple independent fields without duplicating data. If your app needs 'show me all posts tagged firebase AND published after March 2024 AND with more than 10 likes', Firestore handles that natively with composite indexes. RTDB is the right choice when you need sub-100ms sync latency for simple, well-structured data — live cursors, presence systems, real-time chat, multiplayer game state. Firestore is the right choice when you need the query power of a proper document database with real-time capabilities as a secondary feature.
The onDisconnect pattern: this is one of Firebase RTDB's genuinely unique capabilities. onDisconnect() registers an operation on the server that fires when the client connection drops — whether from a graceful close, a network failure, a browser crash, or a power cut. The server executes the registered operation without any client-side code running. This makes building presence systems — tracking which users are currently online — reliable in a way that's nearly impossible to replicate with REST APIs or even raw WebSockets without significant server-side infrastructure.
FirebaseTransactionAndPresence.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import {
getDatabase,
ref,
get,
set,
runTransaction,
onDisconnect,
onValue,
serverTimestamp
} from'firebase/database';
const database = getDatabase();
// ── TRANSACTION: Race-condition-free like counter ────────────────────────────// Without runTransaction(), two simultaneous likes both read 10 and both write 11.// The final count is 11 instead of 12 — data corruption with no error thrown.// runTransaction() serialises concurrent writes on the server and retries on conflict.asyncfunctiontogglePostLike(postId, currentUserId) {
const likesCountRef = ref(database, `posts/${postId}/likesCount`);
const userLikeRef = ref(database, `postLikes/${postId}/${currentUserId}`);
// Check current like state with a one-time read.const alreadyLiked = (awaitget(userLikeRef)).exists();
// runTransaction receives the current server value and returns the new value to write.// If a concurrent write changes the value between the server's read and Firebase's write,// the function re-runs with the updated current value automatically.const { committed, snapshot } = awaitrunTransaction(likesCountRef, (currentCount) => {
if (currentCount === null) return alreadyLiked ? 0 : 1; // handle uninitialised nodereturn alreadyLiked ? Math.max(0, currentCount - 1) : currentCount + 1;
});
if (committed) {
// Update the per-user like index outside the transaction.// This is acceptable here because the counter is the contested resource.awaitset(userLikeRef, alreadyLiked ? null : true);
console.log(`Like ${alreadyLiked ? 'removed' : 'added'}. New total: ${snapshot.val()}`);
} else {
// This rarely fires — it means the transaction was aborted (too many retries).
console.warn('Transaction did not commit — try again.');
}
}
// ── PRESENCE: Knowing who is online right now ────────────────────────────────// onDisconnect is registered on the SERVER before we write the 'I am online' marker.// This is the key ordering: register the cleanup FIRST, then mark as online.// If the network drops between these two lines, the cleanup is already registered// and will still fire.asyncfunctionregisterUserPresence(userId, displayName) {
const userPresenceRef = ref(database, `presence/${userId}`);
// 1. Register the cleanup handler on the server FIRST.// The server will execute this when it stops receiving heartbeats from this client.// This fires on: browser close, tab crash, network failure, power cut.// No client code runs when it fires — it's entirely server-side.awaitonDisconnect(userPresenceRef).remove();
// 2. Now write the online marker.// If the client crashes between these two lines, step 1 has already registered// the cleanup — the server will eventually remove the presence entry anyway.awaitset(userPresenceRef, {
displayName,
onlineSince: serverTimestamp(), // server fills this in — not the client clock
status: 'online'
});
console.log(`${displayName} is now marked as online.`);
}
// ── WATCHING THE LIVE PRESENCE MAP ──────────────────────────────────────────// onValue on the entire presence node — this works well because presence objects// are small and the total number of concurrent users is bounded.// If you had millions of potential users, you'd want a different structure.functionwatchOnlineUsers(onUpdate) {
const presenceRef = ref(database, 'presence');
const unsubscribe = onValue(presenceRef, (snapshot) => {
const presenceData = snapshot.val() ?? {};
const onlineUsers = Object.entries(presenceData).map(([uid, info]) => ({
uid,
displayName: info.displayName,
onlineSince: newDate(info.onlineSince).toLocaleTimeString(),
status: info.status
}));
console.log(`${onlineUsers.length} user(s) online:`);
onlineUsers.forEach(u => console.log(` - ${u.displayName} (since ${u.onlineSince})`));
onUpdate(onlineUsers);
});
return unsubscribe;
}
// ── OFFLINE AWARENESS: Show connection state to users ───────────────────────// .info/connected is a special Firebase path that reflects this client's// connection state. Use it to show an offline banner rather than letting// the user think their actions are live when they're actually queued locally.functionwatchConnectionState(onConnectionChange) {
const connectedRef = ref(database, '.info/connected');
const unsubscribe = onValue(connectedRef, (snapshot) => {
const isConnected = snapshot.val();
console.log(isConnected ? 'Connected to Firebase' : 'Offline — writes are queued');
onConnectionChange(isConnected);
});
return unsubscribe;
}
// ── SIMULATED USAGE ─────────────────────────────────────────────────────────awaitregisterUserPresence('uid_alice', 'Alice Mbeki');
awaitregisterUserPresence('uid_bob', 'Bob Okafor');
const stopWatching = watchOnlineUsers((users) => { /* update UI */ });
watchConnectionState((connected) => { /* show/hide offline banner */ });
await togglePostLike('-NxK8mQpostA', 'uid_bob'); // Bob likes Alice's post
await togglePostLike('-NxK8mQpostA', 'uid_bob'); // Bob un-likes it
Output
Alice Mbeki is now marked as online.
Bob Okafor is now marked as online.
2 user(s) online:
- Alice Mbeki (since 10:14:22 AM)
- Bob Okafor (since 10:15:01 AM)
Connected to Firebase
// togglePostLike — Bob had not liked this post yet:
Like added. New total: 13
// togglePostLike called again immediately:
Like removed. New total: 12
// Bob closes the browser tab — no client code runs:
// Firebase server detects the WebSocket heartbeat has stopped.
// The registered onDisconnect handler fires server-side.
// /presence/uid_bob is deleted automatically.
// The presence watcher fires automatically with the updated state:
1 user(s) online:
- Alice Mbeki (since 10:14:22 AM)
Interview Gold: Why onDisconnect Works Even During Hard Crashes
onDisconnect handlers are registered on the Firebase server the moment you call the function — not when the disconnect happens. The server holds the instruction and executes it when it stops receiving WebSocket heartbeats from the client. This means the cleanup fires even during a hard browser crash, a power loss, or an abrupt network severing — no client code runs at all. This server-side execution model is what makes reliable presence systems possible without a dedicated backend service.
Production Insight
Without runTransaction(), concurrent writes to counters silently produce incorrect results — you get no error, no warning, just a wrong number that compounds over time as users interact.
The offline write queue makes Firebase feel fast on poor connections, but it creates a gap between what the user sees locally and what the server has confirmed. For anything with real-world consequences, wait for the write promise to resolve before showing success.
Never treat the first onValue emission as guaranteed server data — it may be locally cached state from a previous session.
Key Takeaway
runTransaction() is the only correct way to modify contested values — counters, limited inventory, quota tracking — when multiple clients might write simultaneously.
onDisconnect handlers execute server-side even when the client process is dead — they're the architectural foundation of reliable presence systems.
RTDB is purpose-built for simple, fast, real-time sync — switch to Firestore when you need compound queries, and never use RTDB as a general-purpose document database.
● Production incidentPOST-MORTEMseverity: high
Nested Firebase Data Model Downloaded 50MB on Every Chat Load
Symptom
Chat screen took 8-12 seconds to load for users with significant message history. Mobile users on 3G connections timed out consistently. Firebase billing spiked 400% in one week with no corresponding increase in active users. The team initially assumed Firebase infrastructure was the problem.
Assumption
The team assumed Firebase would only download the specific fields you queried, similar to a SQL SELECT on named columns. They nested chat messages inside room objects inside user objects, reasoning that keeping related data together was good practice. In a relational database that reasoning is sometimes valid. In Firebase it is the fastest path to performance collapse.
Root cause
Firebase downloads the entire subtree when you read any node in the tree. Reading /users/uid_alice with nested messages pulled down every message Alice had ever sent across all rooms — 50MB or more for active users with months of history. Worse, the onValue listener on that node re-triggered on every single new message anywhere in Alice's data, causing the entire 50MB to be re-downloaded and re-parsed each time. What looked like one listener was actually a continuous data firehose.
Fix
Restructured the data model to separate flat collections: /messages/{messageId} with an authorId field referencing /users/{uid}, and /rooms/{roomId}/messageIds as an index. Replaced onValue with onChildAdded on the messages collection — onChildAdded fires once per existing message on initial attach, then once per new message, never re-processing history. Added limitToLast(50) to cap initial load at the 50 most recent messages. Firebase billing returned to baseline within 24 hours of the fix deploying.
Key lesson
Never nest data that you'll read independently — always store references (IDs) instead of full embedded objects
onValue re-downloads the entire watched subtree on every single change anywhere in that subtree, regardless of how small the change is
Use onChildAdded for lists — it fires for each existing item on initial attach, then once per new addition, and never re-processes existing items
Always test your data model with production-scale data volumes before launch — 500 test records will never reveal the failure that 50,000 real records produce
Production debug guideWhen your real-time app feels slow or costs spike unexpectedly4 entries
Symptom · 01
Firebase billing spiking — bytes downloaded per month far exceeds expected usage
→
Fix
Open Firebase Console > Realtime Database > Usage tab. Look at bytes downloaded broken down by path. Paths where downloaded bytes grow linearly with content volume — not just with active users — are almost always over-nested data. Each read of a parent node downloads all children recursively. Identify the heaviest path and flatten that section of the tree first.
Symptom · 02
Listeners firing too frequently causing UI jank or excessive re-renders
→
Fix
Audit every onValue and on('value') call in your codebase. Check whether you're using onValue on a list-type node — if so, every new item in that list triggers a full re-download and re-fire. Switch to onChildAdded for any node that represents a growing collection. Also verify that listeners are being removed — use console.log calls around listener attachment and removal to confirm the counts stay balanced.
Symptom · 03
Writes failing silently — the app appears to succeed but data never appears in the console
→
Fix
Firebase does not throw on permission denial — it calls your error callback with a permission_denied code. Check that you're handling the second argument to set/update callbacks and the catch of the returned promise. Use Firebase Console > Realtime Database > Rules > Rules Playground to simulate the exact write with the exact auth token you're using. The playground shows you which rule line caused the denial and why.
Symptom · 04
Stale or inconsistent data showing briefly after a write before correcting itself
→
Fix
This is the offline cache firing before server confirmation. Firebase listeners emit locally cached data immediately, then emit again when the server confirms or corrects the value. For non-critical UI (showing a message as sent) this is fine. For critical operations (payment confirmation, inventory reservation) wait for the write promise to resolve before updating UI state. Never show success for a critical operation based on the first onValue emission.
★ Firebase RTDB Debugging Cheat SheetWhen your real-time database misbehaves in development or production — immediate diagnostics, not theory.
Permission denied errors on reads or writes — silent or bubbling as exceptions−
Immediate action
Test the failing operation in Firebase Console Rules Playground before touching any code
Commands
firebase deploy --only database # ensure the rules file you're editing is actually deployed
console.log(firebase.auth().currentUser?.uid) // confirm the uid matches what your rules expect
Fix now
In Rules Playground: set the path, method (read/write), and paste your auth token. Read the denial reason — it tells you exactly which rule line blocked the request. The most common fix is adding auth !== null as the first condition in your .read or .write rule.
Memory leak — app gets progressively slower the longer a user stays+
Immediate action
Audit all onValue and onChildAdded calls for matching cleanup calls
Commands
// In Chrome DevTools: Memory tab > Take heap snapshot > search for 'Firebase' or 'Repo'
// Track listener count manually: let listeners = 0; increment on attach, decrement on detach, log periodically
Fix now
Every on() or onValue() call must have a corresponding off() or stored unsubscribe call. In React: return the unsubscribe from useEffect. In Vue: call it in onBeforeUnmount. In Angular: call it in ngOnDestroy. No exceptions.
Excessive bandwidth usage on mobile — users on metered connections burning through data+
Immediate action
Check Firebase Console > Usage for bytes downloaded per path, then profile the specific listener
Commands
// In Chrome DevTools: Network tab > filter by firebaseio.com > check WebSocket frames size
// Add .info/connected listener to log all connection/disconnection events and correlate with bandwidth spikes
Fix now
Add limitToLast(50) to list queries to cap initial download. Switch onValue to onChildAdded for growing collections. If a single path is downloading megabytes on attach, that subtree needs to be flattened.
Firebase Realtime Database vs Cloud Firestore
Feature / Aspect
Firebase Realtime Database
Cloud Firestore
Data model
Single JSON tree — one giant nested object, path-based access
Collections and documents — more familiar to anyone from a NoSQL or document DB background
Querying capability
Filter by one field, order by one field — compound queries require data duplication tricks
Compound queries, multiple independent filters, collection group queries across sub-collections
Real-time sync latency
~50-100ms — the fastest Firebase option, optimised for this use case
~100-300ms — still real-time by most definitions, but measurably higher than RTDB
Offline support
Built-in, automatic for web and mobile — zero configuration required
Built-in, more sophisticated conflict resolution, better handling of concurrent offline edits
Pricing model
Charged per GB stored and per GB downloaded — costs scale with data volume transferred
Charged per document read, write, and delete operation — costs scale with operation count
Best for
Chat, live presence, real-time scoreboards, multiplayer gaming state, live cursors
Complex data querying, large structured datasets, multi-region deployments, relational-ish data
Scalability ceiling
Lower — a single JSON tree under very high write load can become a bottleneck, especially on hot paths
Higher — designed from the ground up for massive concurrent writers and multi-region replication
Security rules language
Firebase RTDB rules — JSON-based, cascade downward, cannot be revoked by children
Firestore security rules — similar paradigm but more expressive, supports function definitions
Key takeaways
1
Firebase RTDB stores all data as a single JSON tree
no tables, no SQL, no implicit schema. Every node is independently accessible via its path, and every read downloads the entire subtree beneath the accessed node.
2
Keep data flat
store IDs as references rather than embedding full objects. This is not just a performance best practice, it is the architectural foundation that keeps reads small and predictable as data grows.
3
Security rules are your backend
they are not optional and they are not just for access control. The .validate rule enforces data shape, field types, value ranges, and field immutability without any server code.
4
Rules cascade strictly downward. Access granted at a parent cannot be revoked by a child rule. Design your tree so that data with different access requirements lives at separate top-level paths.
5
Use onChildAdded for lists, not onValue. onValue re-downloads the entire list on every single change. onChildAdded fires once per existing item on attach, then once per new item
it is the correct tool for growing collections.
6
Always store the unsubscribe function from every listener and call it on cleanup. Orphaned listeners accumulate across navigations and cause memory leaks and unnecessary bandwidth charges.
7
runTransaction() is non-negotiable for contested writes
counters, inventory, quotas. Without it, concurrent writes silently corrupt shared values with no error thrown.
8
onDisconnect handlers execute server-side even when the client process is completely dead. They are what makes presence systems reliable in Firebase RTDB
not application code, not heartbeats from the client.
Common mistakes to avoid
6 patterns
×
Leaving security rules in test mode (.read: true, .write: true) after development
Symptom
Anyone on the internet with your project's database URL can read and overwrite your entire database. Firebase shows a warning in the console but does not prevent deployment. You may not notice until someone reports seeing another user's data or until you see writes you did not make.
Fix
Before any production deployment: replace test-mode rules with auth-based rules at every path. Use the Rules Playground in the Firebase Console to simulate both allowed and denied cases for every path. Deploy with firebase deploy --only database and verify the rules are live in the console.
×
Nesting data with different read patterns under the same parent node
Symptom
Reading one user's profile downloads their entire message history or order archive. Firebase billing spikes without a corresponding increase in active users. Page load times climb as user data accumulates. The problem worsens over time as users generate more content.
Fix
Store flat references — IDs — instead of nested objects. If users and posts are ever read independently, they belong as sibling top-level nodes linked by ID fields. Model data by access pattern, not by conceptual relatedness.
×
Using onValue for list data instead of onChildAdded
Symptom
Every new message in a chat room causes the entire message history to re-download and re-render. UI jank is noticeable. Bandwidth usage grows with message history length, not with active users. Users on slow connections see the problem worsen over time as the chat grows.
Fix
Use onChildAdded for any growing list — it fires once per existing item on initial attach, then once per new item, and never re-processes history. Reserve onValue for single values like counters, status flags, or small configuration objects that rarely exceed a few hundred bytes.
×
Not storing and calling the listener unsubscribe function on cleanup
Symptom
App slows down progressively the longer a user stays. Memory usage climbs. Firebase billing increases from active listeners on paths the user is no longer viewing. In single-page apps, the problem compounds across navigation events — 10 chat room visits means 9 orphaned listeners still running.
Fix
Every call to onValue or onChildAdded returns an unsubscribe function. Store it. Call it when the component unmounts, the user navigates, or the user signs out. In React, return it from useEffect. In Vue, call it in onBeforeUnmount. In Angular, call it in ngOnDestroy. Treat it as a required part of the listener definition, not an optional cleanup.
×
Incrementing shared counters with sequential reads and writes instead of transactions
Symptom
Like counts, view counts, and inventory quantities drift silently. The error is invisible — no exception is thrown, no warning appears. The count is simply wrong in a way that becomes obvious only when comparing against an authoritative source or when very high concurrent writes amplify the discrepancy.
Fix
Use runTransaction() for any value that multiple clients might write to simultaneously. The transaction function receives the current server value, returns the new value, and Firebase handles conflict detection and retry automatically. This is the only correct way to safely modify shared counters.
×
Treating the first onValue emission as confirmed server state for critical operations
Symptom
Success UI shows before the server has received the write. In offline scenarios, locally queued writes that later fail (due to rules, network issues, or server errors) leave the user believing an operation succeeded when it didn't. Payment screens and booking confirmations are the highest-risk places for this.
Fix
Wait for the write's returned promise to resolve before showing success UI for any operation with real-world consequences. Use the .info/connected path to monitor connection state and surface an offline indicator to users. Never conflate 'locally queued' with 'server confirmed'.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between get() and onValue() in Firebase RTDB, and...
Q02SENIOR
Why should you keep Firebase data flat instead of deeply nested, and how...
Q03SENIOR
How do Firebase Security Rules cascade and what does that mean for how y...
Q04SENIOR
How does runTransaction() prevent race conditions, and when is it necess...
Q05SENIOR
When would you choose Firebase RTDB over Cloud Firestore for a new proje...
Q01 of 05JUNIOR
What is the difference between get() and onValue() in Firebase RTDB, and how do you decide which to use?
ANSWER
get() performs a one-time read — it fetches the current snapshot and the underlying connection for that read closes. onValue() attaches a persistent listener that fires immediately with current data and then fires again every time that node changes anywhere in the database. Use get() when the data is unlikely to change during the current interaction — profile prefetch on login, username availability check during signup, loading static configuration. Use onValue() when the UI needs to reflect changes made by other users in real time — counters, presence indicators, small live data objects. For growing lists like chat messages or activity feeds, use onChildAdded instead of onValue — it avoids re-downloading existing items every time a new one arrives. In all cases, store the unsubscribe function from onValue or onChildAdded and call it when the component unmounts to avoid memory leaks.
Q02 of 05SENIOR
Why should you keep Firebase data flat instead of deeply nested, and how do you handle relationships between entities?
ANSWER
Firebase downloads the entire subtree when you read any node. If posts are nested inside users, reading a user profile also downloads every post they ever wrote — which can be megabytes for an active user. There is no column-level selection in Firebase equivalent to SQL's SELECT. Flat data keeps reads small and predictable. The pattern for relationships is the same as foreign keys in relational databases: store IDs instead of full objects. A user document stores an array or map of post IDs; the posts themselves live at their own top-level path. The application code resolves the relationship by making a second get() or attaching a listener to the posts path when needed. This keeps user profile reads small regardless of how many posts the user has written, and it means updates to a post only touch one place rather than needing to be propagated to every parent that embeds it.
Q03 of 05SENIOR
How do Firebase Security Rules cascade and what does that mean for how you structure your data tree?
ANSWER
Rules cascade strictly downward. If you grant .read or .write access at a parent path, every child beneath it inherits that access. There is no mechanism to revoke access at a child level if the parent already granted it — a .read: false on a child path has no effect if the parent already has .read: true. This has a direct implication for data structure: data with different access requirements must live at separate top-level paths that can be locked independently. You cannot mix publicly readable product listings with private user data under the same parent node and lock the private data with a child rule. Plan your tree around access patterns, not around conceptual data relationships. Data that belongs to the same domain but has different security requirements should be siblings, not parent and child.
Q04 of 05SENIOR
How does runTransaction() prevent race conditions, and when is it necessary?
ANSWER
runTransaction() reads the current value from the server, passes it to your update function, and attempts to write the returned value atomically. If another client writes to the same path between the read and the attempted write, Firebase detects the conflict, discards the result, re-reads the new current value, and re-runs your function with that updated value. This retry loop continues until the write succeeds cleanly or the transaction is explicitly aborted. It is necessary any time multiple clients might write to the same value concurrently — like counters, quotas, and inventory quantities. Without it, two clients that both read likesCount: 10 at the same moment will both write 11, leaving the count permanently wrong. runTransaction() is also necessary for claim-style operations: 'reserve this seat only if it is currently available' can only be expressed correctly with a transaction that reads the current status and conditionally writes the new status as one atomic server operation.
Q05 of 05SENIOR
When would you choose Firebase RTDB over Cloud Firestore for a new project?
ANSWER
Choose RTDB when sub-100ms synchronisation latency is the primary requirement and the data model is relatively simple — live cursors in a collaborative tool, multiplayer game state, chat messages, presence systems, real-time leaderboards. RTDB's WebSocket connection is optimised for this use case and consistently delivers lower latency than Firestore. Choose Firestore when the application needs compound queries on multiple independent fields — 'all posts in category X published after date Y with more than Z likes' requires Firestore's composite index model; RTDB cannot express that query without denormalising data. Firestore also scales more gracefully under high concurrent write load across many different paths, and has more expressive security rules. The practical rule: RTDB for anything where 'real-time' means 50ms and the data structure is flat; Firestore for anything that requires flexible querying, large structured datasets, or multi-region reliability.
01
What is the difference between get() and onValue() in Firebase RTDB, and how do you decide which to use?
JUNIOR
02
Why should you keep Firebase data flat instead of deeply nested, and how do you handle relationships between entities?
SENIOR
03
How do Firebase Security Rules cascade and what does that mean for how you structure your data tree?
SENIOR
04
How does runTransaction() prevent race conditions, and when is it necessary?
SENIOR
05
When would you choose Firebase RTDB over Cloud Firestore for a new project?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between set() and update() in Firebase RTDB?
set() overwrites the entire node at the specified path. Any data at that path that is not included in the new value is permanently deleted — including sibling fields you never intended to touch. update() modifies only the fields you specify, leaving all other data at that path untouched. Use set() when you control the complete shape of a node and want to replace it entirely, such as creating a new user profile. Use update() for partial modifications, or for the multi-path update pattern where you pass an object of path-to-value pairs to the root ref to write atomically to multiple unrelated paths in one round-trip.
Was this helpful?
02
How does Firebase handle offline data, and what are the implications for my app?
The Firebase SDK caches data locally and queues write operations when the device goes offline. Listeners fire immediately with cached data from the last known state. When connectivity returns, queued writes replay automatically in order, and listeners receive updated data from the server. This gives excellent perceived performance on slow or intermittent connections. The implication: the first emission from onValue may be stale locally cached data, not confirmed server state. For non-critical UI like chat messages or activity feeds, this is acceptable and desirable. For operations with real-world consequences — payments, bookings, inventory changes — always wait for the write's returned promise to resolve before showing success UI. Monitor connection state via the .info/connected special path to show users an offline indicator when appropriate.
Was this helpful?
03
What are Firebase push keys and why do they sort chronologically?
push() generates a unique key using a combination of a millisecond-precision timestamp and random characters. The timestamp is encoded into the first characters of the key, so keys generated at different times sort in chronological order by default — the earliest key is lexicographically smallest. This makes push() ideal for feeds, chat histories, and activity logs: you always know the latest item has the highest-sorting key, and limitToLast(N) reliably returns the N most recent items without additional sorting. The random suffix ensures uniqueness even when thousands of clients generate keys at the same millisecond.
Was this helpful?
04
Can I use Firebase RTDB with server-side rendering?
Firebase RTDB is designed around client-side persistent connections — its value proposition is long-lived WebSocket connections that push updates to clients. For SSR, use the Firebase Admin SDK on the server to perform one-time reads using the Admin SDK's equivalent of get(), fetch the initial data during server-side rendering, and embed it in the HTML response. The client then hydrates and can attach real-time listeners for subsequent updates. Do not attempt to maintain persistent onValue or onChildAdded listeners in an SSR context — the server-side environment is stateless and each request should use one-time reads. The Admin SDK bypasses security rules entirely, so ensure your server-side data access is appropriately scoped.
Was this helpful?
05
What is the maximum data size for a single Firebase RTDB node, and how does that affect design?
A single node can store up to 10MB of data. The total database for the Spark (free) plan is capped at 1GB. On the Blaze (pay-as-you-go) plan there is no hard total limit, but performance degrades as the tree grows and individual nodes approach the 10MB limit. In practice, the more meaningful constraint is bandwidth cost and listener performance — a 5MB node that changes frequently will be re-downloaded in its entirety to every attached listener on every change. Design nodes to stay well under 100KB by using flat references. If you have nodes approaching megabytes, that is a signal that data which should live in a separate flat collection has been nested instead.