Paginating Large Blog Collections in Firebase Realtime Database Without Overfetching
Learn how to paginate large blog collections in Firebase Realtime Database using deterministic sort keys, ordered queries, and limitToFirst/limitToLast—keeping payloads small and costs predictable.
09 Sept 2026, 10:29 UTC

The problem: fetching everything kills performance
When a blog grows beyond a few hundred posts, a naïve ref.once('value') pulls the entire posts subtree. That means megabytes of JSON travel over the wire, the client parses a huge object, and the Realtime Database charges you for every node read. Users see a blank screen while the payload arrives, and your bill climbs.
Ordered queries give you a window, not the whole house
Firebase Realtime Database lets you ask for a slice of data if the data is ordered. The ordering functions—orderByChild, orderByKey, orderByPriority—combined with limitToFirst or limitToLast return only the first or last N children. The catch: the order must be deterministic so that paging forward or backward never skips or repeats a record.
Choosing a stable sort key
A common pattern is to store a createdAt timestamp (milliseconds since epoch) together with the push‑ID that Firebase generates. Concatenating them as createdAt_pushId yields a unique, chronologically sortable key that never changes.
// Example child structure
posts/
-M3f9a1b2c3d4e5f6g7h // pushId
title: "First post"
createdAt: 1730456700000
sortKey: "1730456700000_M3f9a1b2c3d4e5f6g7h"
...
Worked example: first page and the next page
The following snippet runs in a browser or Node.js environment that has the Firebase JavaScript SDK (v9+) installed. You need a Firebase project with Realtime Database enabled and security rules that allow read on /posts for the authenticated user (or public for a demo).
import { initializeApp } from 'firebase/app';
import { getDatabase, ref, query, orderByChild, limitToFirst, startAt, get } from 'firebase/database';
const app = initializeApp({ /* your firebase config */ });
const db = getDatabase(app);
const postsRef = ref(db, 'posts');
const PAGE_SIZE = 10;
async function fetchPage(cursor = null) {
// Build the base query ordered by the deterministic sortKey
let q = query(postsRef, orderByChild('sortKey'), limitToFirst(PAGE_SIZE));
if (cursor) {
// cursor is the sortKey of the last item we already rendered
q = query(q, startAt(cursor));
}
const snapshot = await get(q);
if (!snapshot.exists()) return { items: [], nextCursor: null };
const items = [];
snapshot.forEach(childSnap => {
items.push({ id: childSnap.key, ...childSnap.val() });
});
// The last item's sortKey becomes the cursor for the next request
const nextCursor = items.length ? items[items.length - 1].sortKey : null;
return { items, nextCursor };
}
// Usage
(async () => {
const first = await fetchPage();
console.log('Page 1:', first.items.map(i => i.title));
const second = await fetchPage(first.nextCursor);
console.log('Page 2:', second.items.map(i => i.title));
})();
Run the script with node pagination.js (or include it in a bundler). The first call returns the ten oldest posts; the second call starts after the last sortKey and returns the next ten. No duplicate keys appear because startAt is inclusive—if you want a strict “after” boundary, append a high‑value suffix (e.g., cursor + "\uf8ff") or use endAt with the previous key.
Trade‑offs and hard limits
- Filtering by arbitrary fields (author, tag, status) still requires a separate denormalized index or a Cloud Function that mirrors data into a queryable path. Each extra index adds write overhead and read cost.
- limitToLast reads from the end of the ordered list. If you request a large page (e.g., 500 items) the database may scan many nodes before applying the limit, increasing read counts and latency.
- Single‑query node cap: Realtime Database caps a query at 1 000 children. Requesting more than that returns an error; keep
PAGE_SIZEwell below that ceiling. - Cursor stability: If a post’s
sortKeychanges (e.g., you edit the timestamp), the cursor can point to a different record on the next fetch. TreatsortKeyas immutable after creation.
Quick verification checklist
- Deploy the snippet to a test project and open the Realtime Database console. Confirm the
postsnode containssortKeyvalues that increase monotonically. - Run the script twice. The second page’s first
sortKeyshould be greater than the first page’s lastsortKey. - In the console’s “Usage” tab, verify that each page read reports roughly
PAGE_SIZEnodes (plus a small overhead for the query). No “out of range” warnings should appear. - Insert a new post with a timestamp newer than the existing max. Re‑run the first page fetch; the new post should appear only when you request a page that includes its position.
Takeaway
Pagination in Firebase Realtime Database is straightforward once you impose a stable, sortable key and use orderByChild with limitToFirst (or limitToLast) plus a cursor. The pattern keeps payloads small, costs predictable, and the UI responsive. Remember the 1 000‑node query ceiling and the need for denormalized indexes when you must filter on non‑ordered fields. Implement the verification steps above before shipping to production, and you’ll avoid the classic “load‑everything” bottleneck.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.