Firebase RTDB — Nested Data Costs 50MB Per Load
A Firebase listener downloaded 50MB per chat load from nested data.
20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
Firebase Realtime Database (RTDB) is a NoSQL cloud-hosted JSON tree that synchronizes data in real-time across all connected clients. Unlike traditional REST APIs where you poll for changes, RTDB maintains persistent WebSocket connections—every client subscribes to specific paths and receives delta updates the instant data changes.
This makes it ideal for collaborative apps, live dashboards, and chat systems where sub-second latency matters. The trade-off? You pay for every byte transferred over the wire, and nested data structures can silently balloon your bill—a deeply nested node loaded entirely costs you for the whole tree, not just the fields you need.
RTDB sits alongside Firestore in Firebase's ecosystem, but they solve different problems. Firestore offers richer querying, automatic scaling, and a more structured document model; RTDB gives you lower latency (sub-10ms vs 100-200ms for Firestore) and simpler offline-first semantics via its built-in disk persistence.
Use RTDB when you need true real-time sync with minimal overhead—think multiplayer game state, cursor positions, or IoT sensor feeds. Avoid it for complex relational data, ad-hoc queries, or anything requiring joins; you'll end up denormalizing everything and writing client-side aggregation logic.
Data in RTDB is a single JSON tree—every node is a path like /users/abc123/name. Reading /users fetches the entire subtree, including all nested children. A user profile with 50 fields nested three levels deep might cost 50KB per load, but if you have 1,000 concurrent listeners on that path, you're billed for 50MB every time any child changes.
This is why production RTDB apps aggressively flatten data: store user names at /usernames/{uid} and profile details at /profiles/{uid}, never under a single /users/{uid} node. Security rules are your only server-side gatekeeper—they evaluate at the path level, so you must structure data to match your access patterns or risk exposing everything.
Real-world patterns demand transactions for counters or inventory (they retry on conflict), offline support via keepSynced(true) for critical paths, and aggressive data denormalization. When NOT to use RTDB: any app needing SQL-style queries, complex aggregations, or data that changes infrequently.
For those, Firestore or a traditional backend with PostgreSQL will save you money and headaches. RTDB excels at one thing—blazing-fast, always-on sync—but it punishes poor schema design with a direct line to your wallet.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Firebase RTDB — The Real-Time Sync Engine That Bills You for Every Byte
Firebase Realtime Database (RTDB) is a cloud-hosted NoSQL database that synchronizes data across connected clients in real time. Its core mechanic: every client maintains an open WebSocket connection to a single JSON tree, and any change at any node is pushed to all subscribed listeners. There is no query engine — you read and write entire subtrees. The pricing model is based on data transferred, not stored, so a deeply nested structure can cost 50 MB per load even if you only need a single field. In practice, RTDB stores data as one giant JSON blob; you cannot fetch a child without downloading its parent. This means a chat app with 10,000 messages under a single node forces every client to download all 10,000 messages on every connection. The only way to limit data is to flatten your schema or use shallow queries (REST shallow=true), but those break real-time listeners. Use RTDB when you need low-latency, multi-client sync for small, flat datasets — think presence indicators or collaborative cursors. For anything with nested lists or large payloads, Firestore or a custom WebSocket layer is cheaper and more predictable.
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.
// This is what your Firebase Realtime Database 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": { // Firebase push() 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" } } }
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.
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()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: overwrites the entire node at a path (which will delete sibling fields if you're not careful), set() modifies only the fields you name and leaves everything else alone, update() 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.push()
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. async function loadUserProfile(userId) { const userRef = ref(database, `users/${userId}`); const snapshot = await get(userRef); if (!snapshot.exists()) { console.log('No profile found for user:', userId); return null; } // .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. async function createUserProfile(userId, displayName, email) { const userRef = ref(database, `users/${userId}`); await set(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. async function publishPost(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. await update(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. function subscribeToChatMessages(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. function watchOnlineUserCount(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. async function deletePost(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 data await update(ref(database), updates); console.log('Post and all references deleted:', postKey); }
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.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.
// Deploy with: firebase deploy --only database // Or paste into Firebase Console > Realtime Database > Rules tab // Test every change in the Rules Playground before deploying to production { "rules": { // ── PUBLIC READ, ADMIN-ONLY WRITE ───────────────────────────────────── "products": { // Unauthenticated visitors (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-ONLY WRITE WITH DATA VALIDATION ───────────────────────────── "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 — Firebase Auth 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 — AUTHENTICATED CREATE, OWNER EDIT/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 — USER CAN WRITE ONLY THEIR OWN PRESENCE ───────────────── "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" } }, // ── DENY ALL UNSPECIFIED PATHS ───────────────────────────────────────── // 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 } } }
.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.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.
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. async function togglePostLike(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 = (await get(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 } = await runTransaction(likesCountRef, (currentCount) => { if (currentCount === null) return alreadyLiked ? 0 : 1; // handle uninitialised node return 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. await set(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. async function registerUserPresence(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. await onDisconnect(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. await set(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. function watchOnlineUsers(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: new Date(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. function watchConnectionState(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 ───────────────────────────────────────────────────────── await registerUserPresence('uid_alice', 'Alice Mbeki'); await registerUserPresence('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
Data Denormalization — Why Your Inner SQL Dev Will Scream (and Get Over It)
Coming from SQL, your first instinct is to normalize. Don't. Firebase RTDB is a JSON tree, not a relational database. Every query fetches an entire node. There's no JOIN, no WHERE clause deeper than one level, no aggregate functions. If you nest a user's data inside a chat message node, you'll download the whole chat history just to render a username. That costs money and latency.
The pattern is denormalize aggressively. Duplicate data across nodes. Store a user's displayName inside every message they write. Maintain a separate /users/{uid} node for the canonical profile, and accept that you'll update it in five places when the name changes. Use a multi-path update to keep it atomic. It feels wasteful. It's not. It's the RTDB tax for real-time sync. Complain once, then write the helper functions and move on.
// io.thecodeforge — database tutorial // Writing a message with duplicated user data for fast reads const messageRef = ref(db, 'messages/chatRoom42'); const messageData = { text: 'Anyone seen the prod deploy logs?', timestamp: Date.now(), userId: 'user_abc123', displayName: 'Alice Chen', avatarUrl: 'https://cdn.example.com/avatars/alice.webp' }; // Also update the user's canonical profile const updates = {}; updates[`messages/chatRoom42/${pushKey}`] = messageData; updates[`users/user_abc123/lastMessage`] = messageData.text; runTransaction(ref(db), updates).then(() => { console.log('Message written + user profile updated atomically'); });
How to Set Up Firebase RTDB — The Only Steps That Matter
You don't need a 12-step wizard. Three things matter: a Firebase project, a database instance, and an SDK config that doesn't end up in your public GitHub repo.
First, go to the Firebase Console, create a project, and enable Realtime Database. Choose 'Start in test mode' for now — you'll lock it down with security rules before you ship. The database URL looks like https://your-project-default-rtdb.region.firebasedatabase.app/. Save it. You'll need it.
Second, add the Firebase SDK to your app. For web, it's npm install firebase. For mobile, add the BoM and the RTDB dependency. Initialize it with your project config — that JSON blob with apiKey, authDomain, databaseURL, etc. The databaseURL is the critical one. Miss it, and writes silently fail.
Third, test connectivity. Write a simple heartbeat payload: { "/heartbeat": { ".sv": "timestamp" } }. If you see a timestamp in the console, you're live. If not, check your config, your security rules, and your network. No logs? Add a .catch() on every write. Debug mode is your friend — set the Firebase config's databaseDebugLevel to 3 in dev.
// io.thecodeforge — database tutorial // Minimal Firebase RTDB setup for a React app import { initializeApp } from 'firebase/app'; import { getDatabase, ref, set } from 'firebase/database'; const firebaseConfig = { apiKey: process.env.REACT_APP_API_KEY, authDomain: process.env.REACT_APP_AUTH_DOMAIN, databaseURL: process.env.REACT_APP_DATABASE_URL, // mandatory projectId: 'your-project-id', storageBucket: 'your-project-id.appspot.com', messagingSenderId: '123456789', appId: '1:123456789:web:abc123def456' }; const app = initializeApp(firebaseConfig); const db = getDatabase(app); // Quick connectivity test set(ref(db, 'heartbeat'), { timestamp: Date.now() }) .then(() => console.log('RTDB online')) .catch(err => console.error('RTDB write failed:', err));
Working with Data — The Four Operations That Don't Work Like SQL
Firebase RTDB has four CRUD operations: set, push, update, and remove. None of them work like SQL's INSERT, SELECT, UPDATE, DELETE. Learn the differences or debug silent data loss at 2 AM.
overwrites the entire node. Use it for creating or replacing data. set() generates a unique key — use it for lists like chat messages, logs, or events. It's not a SQL auto-increment; it's a timestamp-based key that sorts chronologically. push() merges data shallowly. It only overwrites the keys you pass — great for partial updates like editing a user's email without wiping their display name. update() deletes a node. No cascading. If you delete a parent, all children are gone. Bob's your uncle, Alice's data is in the void.remove()
Reading is either (real-time listener) or on() (one-time read). get() keeps a socket open. It costs you. Use it only for data that changes live, like cursor positions or chat messages. For static data like a user profile that edits once a month, use on() and cache it. Don't be the dev who opens 500 listeners and wonders why the monthly bill hit five figures.get()
// io.thecodeforge — database tutorial
import { ref, set, push, update, remove, onValue, get } from 'firebase/database';
// CREATE / REPLACE
set(ref(db, 'users/alice'), { email: 'alice@example.com', role: 'admin' });
// PUSH (unique key for a list)
const messagesRef = ref(db, 'messages');
push(messagesRef, { text: 'Hello', from: 'alice' });
// UPDATE (partial, shallow merge)
update(ref(db, 'users/alice'), { lastLogin: Date.now() });
// DELETE
remove(ref(db, 'users/bob'));
// REAL-TIME LISTENER (use sparingly)
onValue(ref(db, 'messages/chat1'), (snapshot) => {
console.log('New message:', snapshot.val());
});
// ONE-TIME READ (preferred for static data)
get(ref(db, 'users/alice')).then((snap) => {
if (snap.exists()) console.log('User:', snap.val());
});update() only does shallow merges. If you have nested data like users/alice/settings/theme, an update on users/alice with just { "settings": { "notifications": true } } will delete the theme key. Use dot-notation paths in the updates object: { 'users/alice/settings/notifications': true }.on() only for live data. For everything else, get() and cache. Your wallet will thank you.Nested Firebase Data Model Downloaded 50MB on Every Chat Load
- 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
firebase deploy --only database # ensure the rules file you're editing is actually deployedconsole.log(firebase.auth().currentUser?.uid) // confirm the uid matches what your rules expect// 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 periodicallyon() 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.// 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| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| RealtimeDatabaseStructure.json | { | How Firebase Structures Data |
| FirebaseReadWritePatterns.js | getDatabase, | Reading and Writing Data |
| database.rules.json | { | Security Rules |
| FirebaseTransactionAndPresence.js | getDatabase, | Real-World Patterns |
| DenormalizedChatWrite.sql | const messageRef = ref(db, 'messages/chatRoom42'); | Data Denormalization |
| FirebaseInitWeb.sql | const firebaseConfig = { | How to Set Up Firebase RTDB |
| CRUDOperations.sql | set(ref(db, 'users/alice'), { email: 'alice@example.com', role: 'admin' }); | Working with Data |
Key takeaways
Common mistakes to avoid
6 patternsLeaving security rules in test mode (.read: true, .write: true) after development
firebase deploy --only database and verify the rules are live in the console.Nesting data with different read patterns under the same parent node
Using onValue for list data instead of onChildAdded
Not storing and calling the listener unsubscribe function on cleanup
Incrementing shared counters with sequential reads and writes instead of transactions
Treating the first onValue emission as confirmed server state for critical operations
.info/connected path to monitor connection state and surface an offline indicator to users. Never conflate 'locally queued' with 'server confirmed'.Interview Questions on This Topic
What is the difference between get() and onValue() in Firebase RTDB, and how do you decide which to 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.Why should you keep Firebase data flat instead of deeply nested, and how do you handle relationships between entities?
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.How do Firebase Security Rules cascade and what does that mean for how you structure your data tree?
How does runTransaction() prevent race conditions, and when is it necessary?
When would you choose Firebase RTDB over Cloud Firestore for a new project?
Frequently Asked Questions
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.
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.
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.
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.
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.
20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.
That's NoSQL. Mark it forged?
9 min read · try the examples if you haven't