React Firebase Real-Time Dashboard: Firestore Counters, Rules and Cost - NextGenBeing
Back to discoveries

React Firebase Real-Time Dashboard: Firestore Counters, Rules and Cost

Build a React Firebase real-time dashboard in the emulator: sharded counters, tested rules, poll-vs-rollup rule and a per-tab cost table.

DevOps Premium Content 14 min read
Bekzod Erkinov

Bekzod Erkinov

Apr 21, 2026 • 98 views
React Firebase Real-Time Dashboard: Firestore Counters, Rules and Cost
Photo by Growtika on Unsplash
Size:
Height:
14 min read 6,024 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

React Firebase Real-Time Dashboard: Firestore Counters, Rules and Cost

A live dashboard on Firestore gets expensive or breaks when many visitors write at once: one hot document throttles, and every open tab with a listener is billed a read per write. This post gives a rule for when to poll and when to listen, a sharded counter with security rules tested in the emulator, and a cost table per open tab.

Decision rule. Poll a sum() aggregation every 30 s for a counter that changes constantly. Attach an onSnapshot listener only to one rollup document that a scheduled job rewrites. Never listen to the raw shards of a busy site.

Approach (model ceilings) Reads per tab per day
onSnapshot on shards, 1 write/s 86,400 (+10 initial)
Poll sum() every 5 s 17,280
Poll sum() every 30 s 2,880
onSnapshot on a rollup written every minute 1,440 (+1 initial)

The numbers are a model, not a measurement. They assume one billed read per write per open listener, inferred from the pricing page and emulator delivery counts. The last section describes how to confirm or break it. Related reading: Firebase posts and React posts on nextgenbeing.com.

Install

Versions installed on 2026-10-07: firebase 12.19.0, firebase-tools 15.32.1, @firebase/rules-unit-testing 5.0.2, react and react-dom 19.3.0, vite 8.3.3, vitest 5.0.3, firebase-admin 14.5.0, plus @vitejs/plugin-react, @testing-library/react and jsdom. Node 24.19.0, OpenJDK 21.0.8.

npm init -y && npm pkg set type=module
npm i firebase react react-dom firebase-admin
npm i -D firebase-tools @firebase/rules-unit-testing vite @vitejs/plugin-react vitest @testing-library/react jsdom

"type": "module" is required, since the files use import.

Firestore distributed counter example: shards anonymous visitors can write

The obvious design is one document, stats/pageviews, with count: increment(1) per visit. Firestore's distributed counters guide says you can't update a single document at an unlimited rate; the fix is a subcollection of shards: write to a random one, read by summing all. Its example says each shard can support being incremented once per second. The best-practices page says the single-document ceiling "depends highly on the workload".

From that, my rule of thumb (not a documented limit): shards ≈ peak sustained writes per second. A site peaking near 10 views a second needs about 10 shards; below 1 a second, one document is enough. I use 10.

stats/pageviews/shards/0 … 9   { count:  }

The rules below allow an anonymous create only with count == 1 and an update only with count + 1, and anonymous visitors can't read. That rules out a transaction or get-then-write. I printed what updateDoc does on a missing shard under these rules: it fails with permission-denied, not not-found, because the update rule can't evaluate against a missing document. So record.js falls back to a create on either code, and rethrows anything else:

import { doc, setDoc, updateDoc, increment } from 'firebase/firestore';

// Anonymous write path. The rules allow create only with count == 1 and update only
// with count + 1, and anonymous visitors cannot read, so no transaction or get() is possible.
// Only these codes trigger the fallback; network errors ('unavailable', ...) are rethrown.
const MISSING = ['not-found', 'permission-denied'];

export async function recordView(db, shards = 10, ops = { setDoc, updateDoc }) {
  const ref = doc(db, 'stats/pageviews/shards', String(Math.floor(Math.random() * shards)));
  try {
    await ops.updateDoc(ref, { count: increment(1) });        // shard exists: +1
  } catch (e) {
    if (!MISSING.includes(e.code)) throw e;
    try {
      await ops.setDoc(ref, { count: 1 });                    // shard missing: create with 1
    } catch (e2) {
      if (!MISSING.includes(e2.code)) throw e2;
      await ops.updateDoc(ref, { count: increment(1) });      // lost the create race: +1
    }
  }
}

A response lost after the server applied the write still surfaces as an error, so a caller retry can over-count; for a page-view tile that is fine. For billing-grade numbers, pre-create the shards with the Admin SDK and keep only the first write.

Run the Firestore and Auth emulators

firebase.json:

{"firestore":{"rules":"firestore.rules"},"emulators":{"auth":{"host":"127.0.0.1","port":9099},"firestore":{"host":"127.0.0.1","port":8080},"ui":{"enabled":false},"singleProjectMode":true}}

vitest.config.js:

export default { esbuild: { jsx: 'automatic' }, test: { include: ['ui.test.jsx'] } };

Java is required. The Firestore emulator is a Java jar, and the CLI refuses to start it without java on PATH. Check java -version first. Use a project ID starting with demo-; the CLI then treats it as a demo project and refuses to touch real services. firebase emulators:exec --only firestore --project demo-dash "" starts the emulator, runs the command and shuts down, so run the tests through it.

Firestore security rules emulator tests

Anonymous visitors bump the counter and nothing else; only an admin reads it. stats/live is the rollup document used later: admins read it, and no client writes it (the Admin SDK bypasses rules). firestore.rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isAdmin() { return request.auth != null && request.auth.token.admin == true; }

    // Anyone may record a page view into one of 10 shards, +1 only.
    match /stats/{counter}/shards/{shard} {
      allow read: if isAdmin();
      allow create: if int(shard) >= 0 && int(shard) < 10
                    && request.resource.data.keys().hasOnly(['count'])
                    && request.resource.data.count == 1;
      allow update: if int(shard) >= 0 && int(shard) < 10
                    && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['count'])
                    && request.resource.data.count == resource.data.count + 1;
    }
    // Rollup document: admins read, only the Admin SDK writes (it bypasses rules).
    match /stats/live { allow read: if isAdmin(); }
    match /{document=**} { allow read, write: if false; }
  }
}

rules.test.js uses Node's built-in node:test, so it runs with node --test, not Vitest:

import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
import { doc, setDoc, updateDoc, getDoc, getDocs, collection, increment } from 'firebase/firestore';
import { recordView } from './record.js';
import { readFileSync } from 'node:fs';
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';

let env;
before(async () => {
  env = await initializeTestEnvironment({ projectId: 'demo-dash',
    firestore: { rules: readFileSync('firestore.rules', 'utf8'), host: '127.0.0.1', port: 8080 } });
});
after(() => env.cleanup());

const shard = (db, n) => doc(db, 'stats/pageviews/shards', String(n));
const anon = () => env.unauthenticatedContext().firestore();

test('anonymous visitor can create a shard with count 1', async () => {
  await assertSucceeds(setDoc(shard(anon(), 0), { count: 1 }));
});
test('anonymous visitor can increment by 1', async () => {
  await assertSucceeds(updateDoc(shard(anon(), 0), { count: increment(1) }));
});
test('anonymous visitor cannot increment by 1000', async () => {
  await assertFails(updateDoc(shard(anon(), 0), { count: increment(1000) }));
});
test('anonymous visitor cannot write shard 99', async () => {
  await assertFails(setDoc(shard(anon(), 99), { count: 1 }));
});
test('anonymous visitor cannot read the counter', async () => {
  await assertFails(getDoc(shard(anon(), 0)));
});
test('admin claim can read the counter', async () => {
  await assertSucceeds(getDoc(shard(env.authenticatedContext('u1', { admin: true }).firestore(), 0)));
});
test('signed-in user without admin claim cannot read', async () => {
  await assertFails(getDoc(shard(env.authenticatedContext('u2').firestore(), 0)));
});
test('stats/live: admin reads, nobody writes from a client', async () => {
  const admin = env.authenticatedContext('u1', { admin: true }).firestore();
  await assertSucceeds(getDoc(doc(admin, 'stats/live')));
  await assertFails(setDoc(doc(admin, 'stats/live'), { total: 1 }));
  await assertFails(getDoc(doc(anon(), 'stats/live')));
});

// --- write path: cold shards, no pre-seeding ---
test('recordView on an empty collection: 25 concurrent visitors, none lost', async () => {
  await env.

Unlock Premium Content

You've read 30% of this article

What's in the full article

  • Complete step-by-step implementation guide
  • Working code examples you can copy-paste
  • Advanced techniques and pro tips
  • Common mistakes to avoid
  • Real-world examples and metrics

Join 10,000+ developers who love our premium content

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.