costguard
v0.9.1
Published
Catches Firebase cost traps before they ship: unbounded Firestore and Realtime Database reads, listener leaks, React render loops, Cloud Function trigger loops, exposed Gemini keys and open security rules.
Maintainers
Readme
Stop Firebase bill shock before it hits production — CostGuard catches expensive patterns as you type, blocks risky commits, and gates your deploys.

CostGuard
Catch expensive Firebase and React patterns before they hit production — and before they hit your bill.
CostGuard is a VS Code extension and CLI that detects the code patterns behind surprise Firebase bills: unbounded Firestore and Realtime Database reads, listeners that never clean up, React render loops, Cloud Function trigger loops, API keys exposed to the browser, and open security rules. It adds inline squiggles, a per-file risk score, and optional gates that block bad code from being committed, merged, or deployed.
Install from the VS Code Marketplace →
What it catches
25 rules, each explained with a bad/fix example under What it detects:
| Area | Rules |
|---|---|
| Firestore reads and writes | FCG002 unbounded read · FCG005 read in loop · FCG012 unbatched writes · FCG013 polling · FCG014 client-side filter · FCG015 read-modify-write · FCG023 offset() pagination |
| React effects and handlers | FCG001 unstable deps · FCG003 listener on UI state · FCG009 read in render · FCG010 compound render loop · FCG011 high-frequency handler · FCG018 missing deps array |
| Missing cleanup | FCG004 onSnapshot · FCG006 setInterval · FCG007 addEventListener · FCG022 Realtime Database listener |
| Cloud Functions | FCG016 unexported function · FCG017 callable in a loop · FCG019 trigger writes to its own document · FCG024 idle minInstances |
| Other billed surfaces | FCG021 unbounded Realtime Database read · FCG020 Gemini key in client code · FCG025 open security rules · FCG008 fetch/axios in a loop |
Example: one unbounded read can quietly cost ~$90/month
// Bad — reads every document in the collection, on every load
const snap = await getDocs(collection(db, 'invoices')); // ← FCG002If invoices has 10,000 documents and this query runs on a dashboard
visited by 500 users a day:
10,000 docs × 500 loads/day = 5,000,000 reads/day
5,000,000 × 30 days = 150,000,000 reads/month
150,000,000 / 100,000 × $0.06 (Firestore read price) ≈ $90/month— for one query, before the feature that uses it has shipped. CostGuard flags this the instant you type it, with the fix inline:
// Fix — cap it, then paginate or query for more as needed
const snap = await getDocs(query(collection(db, 'invoices'), limit(50)));Features
Live diagnostics
Squiggles appear as you type (500ms debounce). No save required.
Copy fix prompt for AI
Click the lightbulb (or Cmd/Ctrl+.) on any squiggle and choose Copy fix prompt for AI. CostGuard copies the violation message plus the surrounding code to your clipboard as a ready-to-paste prompt — drop it straight into ChatGPT, Claude, or Copilot Chat to get a fix.
Risk scoring
Every flagged file gets a score and a breakdown by risk category, visible inline and in the status bar.
$(shield) Risk Score: 82/100 Cost: HIGH Scalability: MEDIUM Memory Leak: HIGHDeployment gates
Three layers that stop risky code before it ships:
| Gate | When it runs | Blocks on |
|---|---|---|
| Pre-commit hook | git commit | HIGH risk |
| GitHub Actions PR gate | Every pull request | HIGH risk |
| Deploy gate | firebase deploy / npm run deploy | MEDIUM+ risk |
Installation
From the Marketplace (recommended): open VS Code → Extensions → search for CostGuard, or run:
code --install-extension soarone.costguardFrom a VSIX (offline installs): download the .vsix from the GitHub releases page, then Extensions → ··· menu → Install from VSIX.
After install, the setup prompt appears automatically — choose which protection layers to enable.
Setup Wizard
On first install (and after major version upgrades), a notification appears a few seconds after VS Code loads. Click Set Up Now to open the protection-layer picker:
CostGuard — Choose your protection layers
● Pre-commit Hook Block commits with HIGH risk violations
● GitHub Actions PR Gate Post risk card on PRs, block HIGH risk merges
○ Deploy Gate Block firebase deploy / npm run deploy on MEDIUM+ riskSelect what you want, press Enter. CostGuard writes all the necessary files automatically — no manual path setup.
To re-run the wizard at any time: Command Palette → CostGuard: Setup
What it detects
FCG001 — Unstable useEffect dependency error
Objects, arrays, functions, or call results used as useEffect dependencies without useMemo/useCallback. Every render creates a new reference, causing the effect to re-run infinitely — the root cause of most Firestore read runaway bills.
// Bad — query is a new object on every render
const query = { collection: 'invoices', status: 'open' };
useEffect(() => { fetchData(query); }, [query]); // ← FCG001
// Fix
const query = useMemo(() => ({ collection: 'invoices', status: 'open' }), []);FCG002 — Unbounded Firestore read error
getDocs or collection calls without .limit(). On a large collection this can read thousands of documents in one call.
// Bad
const snap = await getDocs(collection(db, 'invoices')); // ← FCG002
// Fix
const snap = await getDocs(query(collection(db, 'invoices'), limit(50)));FCG003 — Real-time listener on UI state warning
onSnapshot listeners that re-register whenever UI state like activeTab, selectedId, or showModal changes. Each re-registration opens a new listener and the old one may not be cleaned up.
// Bad — re-registers every time activeTab changes
useEffect(() => {
return onSnapshot(collection(db, activeTab), ...);
}, [activeTab]); // ← FCG003FCG004 — onSnapshot without cleanup error
Real-time listeners must return their unsubscribe function. Without it, every component mount adds a new listener that never closes.
// Bad — listener leaks on every remount
useEffect(() => {
onSnapshot(collection(db, 'invoices'), handler); // ← FCG004
}, []);
// Fix
useEffect(() => {
return onSnapshot(collection(db, 'invoices'), handler); // return the unsub
}, []);FCG005 — Firestore read inside a loop error
getDoc or getDocs calls inside for, while, forEach, map, or similar constructs. Each iteration makes a separate network round-trip (N+1 reads).
// Bad — one read per invoice
for (const id of invoiceIds) {
const doc = await getDoc(doc(db, 'invoices', id)); // ← FCG005
}
// Fix — use getAll / batched reads / a single queryFCG006 — setInterval without cleanup error
setInterval inside useEffect without a corresponding clearInterval return. Intervals stack up on every remount, polling Firebase endlessly.
// Bad
useEffect(() => {
setInterval(() => fetchStats(), 5000); // ← FCG006
}, []);
// Fix
useEffect(() => {
const id = setInterval(() => fetchStats(), 5000);
return () => clearInterval(id);
}, []);FCG007 — addEventListener without cleanup error
addEventListener inside a useEffect with no removeEventListener returned. Each remount adds a duplicate listener that's never removed, so handlers fire multiple times and the component leaks memory.
// Bad — listener leaks on every remount
useEffect(() => {
window.addEventListener('resize', handler); // ← FCG007
}, []);
// Fix
useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);FCG008 — fetch/axios inside a loop error
fetch or axios calls inside for, while, forEach, map, or similar constructs. The same N+1 problem as FCG005, but for any billed HTTP API (Stripe, OpenAI, maps, etc.) instead of Firestore.
// Bad — one HTTP request per item
for (const id of ids) {
await fetch(`/api/items/${id}`); // ← FCG008
}
// Fix — fan out in parallel or use a bulk endpoint
await Promise.all(ids.map(id => fetch(`/api/items/${id}`)));FCG009 — Firestore read in component body error
getDoc/getDocs (or a Cloud Function call) made directly in a component or hook body instead of inside useEffect. It re-fires on every render with no caching — a component re-rendering 10×/second runs 10 reads/second.
// Bad — refetches every render
function Invoice({ id }: { id: string }) {
const snap = getDoc(doc(db, 'invoices', id)); // ← FCG009
}
// Fix
useEffect(() => { getDoc(doc(db, 'invoices', id)).then(setInvoice); }, [id]);FCG010 — Compound render loop error
A useEffect with both an unstable dependency (new object/array/function reference every render) and an expensive operation (getDoc, onSnapshot, fetch) in its body. The two problems amplify each other: the effect re-runs every render, fires the expensive call, which may update state and re-render — an infinite billing loop.
// Bad — config is a new reference every render, and onSnapshot reads on every re-run
const config = getFirebaseConfig();
useEffect(() => {
return onSnapshot(collection(db, 'dunning_log'), handler);
}, [config]); // ← FCG010
// Fix — stabilize the dependency first
const config = useMemo(() => getFirebaseConfig(), []);FCG011 — Expensive operation in a high-frequency handler error
getDoc, fetch, or similar calls inside scroll, mousemove, resize, keydown, or input handlers with no debounce/throttle. These events fire up to hundreds of times per second.
// Bad — fires a read on every scroll event
window.addEventListener('scroll', () => getDoc(ref)); // ← FCG011
// Fix
window.addEventListener('scroll', debounce(() => getDoc(ref), 300));FCG012 — Unbatched Firestore writes in a loop error
addDoc/setDoc/updateDoc/deleteDoc inside a loop fires one separate billed write and network round-trip per iteration.
// Bad — one write per item
for (const item of items) {
await setDoc(doc(db, 'items', item.id), item); // ← FCG012
}
// Fix — up to 500 ops per batch
const batch = writeBatch(db);
items.forEach(item => batch.set(doc(db, 'items', item.id), item));
await batch.commit();FCG013 — Polling Firestore with setInterval warning
setInterval/recursive setTimeout wrapping getDoc/getDocs polls on every tick regardless of whether the data changed — every tick is a billed read.
// Bad — billed read every 5 seconds, changed or not
setInterval(() => getDocs(collection(db, 'orders')), 5000); // ← FCG013
// Fix — only fires when data actually changes
onSnapshot(collection(db, 'orders'), handler);FCG014 — Client-side filtering of an unfiltered read warning
.filter()/.find() applied to a getDocs() result with no .where() clause. The entire collection is fetched and billed before most of it is discarded.
// Bad — fetches everything, keeps a fraction
const snap = await getDocs(collection(db, 'orders'));
const pending = snap.docs.filter(d => d.data().status === 'pending'); // ← FCG014
// Fix — filter server-side
const q = query(collection(db, 'orders'), where('status', '==', 'pending'));
const snap = await getDocs(q);FCG015 — Read-modify-write instead of FieldValue atomics warning
Reading a document, mutating an array/counter field in JS, then writing the whole document back. Costs an extra read and loses updates under concurrent writes.
// Bad — extra read, race condition under concurrent writes
const data = (await getDoc(ref)).data();
await updateDoc(ref, { count: data.count + 1 }); // ← FCG015
// Fix — atomic, no read needed
await updateDoc(ref, { count: increment(1) });FCG016 — Cloud Function defined but not exported warning
A Cloud Function assigned to a variable without export. Firebase will never deploy it — it's dead weight in the functions bundle that slows cold starts for every other function in the file.
// Bad — never deployed
const onUserCreate = onDocumentCreated('users/{id}', handler); // ← FCG016
// Fix
export const onUserCreate = onDocumentCreated('users/{id}', handler);FCG017 — Cloud Function invoked in a loop error
A function created with httpsCallable() invoked inside a loop. Each call bills a separate Cloud Function execution — N items costs N executions instead of one.
// Bad — one billed execution per item
for (const item of items) {
await sendNotification(item); // ← FCG017
}
// Fix — redesign for a batch payload, or fan out in parallel
await Promise.all(items.map(item => sendNotification(item)));FCG018 — useEffect with no dependency array error
useEffect(() => { ... }) with the dependency array omitted re-runs after every render. If the body reads Firestore and the result updates state, that render re-runs the effect — an unbounded read loop that runs as fast as the browser can render. A missing [] is the single most common way a React app burns its daily read quota in minutes.
// Bad — no deps array: re-runs every render, setOrders causes another render
useEffect(() => {
return onSnapshot(query(collection(db, 'orders'), limit(20)), snap => {
setOrders(snap.docs); // ← FCG018
});
});
// Fix — [] runs it once on mount; list real dependencies if it has any
useEffect(() => {
return onSnapshot(query(collection(db, 'orders'), limit(20)), snap => {
setOrders(snap.docs);
});
}, []);Only fires when the effect body does expensive work (Firestore read, listener, fetch/axios, or a callable). A no-deps effect that just sets document.title is left alone. FCG001 and FCG010 analyze the contents of a deps array and deliberately skip effects that have none, so this is a separate code you can suppress independently.
FCG019 — Cloud Function trigger writes to its own document error
A Firestore or Realtime Database trigger that writes back to the document that fired it re-triggers itself. Every cycle bills a function invocation and a write, and nothing stops it — no user traffic required. This is the failure behind the widely-reported $72k overnight bill, and one of only two causes Firebase names explicitly in its avoid surprise bills guidance (FCG002 covers the other).
// Bad — the update re-fires this same trigger, forever
export const touch = onDocumentWritten('users/{userId}', async (event) => {
await event.data.after.ref.update({ updatedAt: Date.now() }); // ← FCG019
});
// Fix — exit early when nothing you care about changed
export const touch = onDocumentWritten('users/{userId}', async (event) => {
const before = event.data.before.data();
const after = event.data.after.data();
if (before.name === after.name) return null;
await event.data.after.ref.update({ nameLower: after.name.toLowerCase() });
});Covers v2 (onDocumentWritten/onDocumentUpdated, onValueWritten/onValueUpdated) and the v1 chained syntax (functions.firestore.document(...).onWrite(...)).
Two deliberate limits keep false positives down: onCreate/onDelete triggers are never flagged, because a write-back from those emits a different event type and terminates; and a handler that references before is treated as guarded, since a before/after comparison is the standard way to break the cycle.
FCG020 — Gemini API key reachable from the browser error
The raw Gemini SDK (@google/generative-ai) authenticates with a plain API key. Ship it in client code and the key sits in your JavaScript bundle, where anyone can scrape it and bill inference to your project. Reported thefts have reached five figures within hours, and token-billed inference has no natural ceiling.
// Bad — NEXT_PUBLIC_/VITE_/REACT_APP_ are inlined into the browser bundle by design
const genAI = new GoogleGenerativeAI(process.env.NEXT_PUBLIC_GEMINI_API_KEY); // ← FCG020
// Fix — keep the key on the server
export const ask = onCall(async (req) => {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
return (await genAI.getGenerativeModel({ model: 'gemini-2.0-flash' })
.generateContent(req.data.prompt)).response.text();
});Two things this rule deliberately leaves alone. firebase/ai (Firebase AI Logic) is designed for client use — it embeds no raw key and is protected by App Check, so flagging it would punish the pattern Firebase recommends. And apiKey: 'AIza...' inside a firebaseConfig object is documented as not-a-secret and belongs in client code; flagging it would fire on virtually every Firebase app. This rule reacts only to a key handed to the AI SDK. Server-side use is untouched — a file importing firebase-functions/firebase-admin or defining onCall/onRequest is treated as backend.
FCG021 — Unbounded Realtime Database read error / warning
Realtime Database bills by bytes downloaded, not per document. A listener without a limit ships the whole subtree on the first callback and again on every change; a listener at the root ships your entire database. Firebase names both in its optimization guidance.
// Bad — the entire database, on every change (error)
onValue(ref(db, '/'), cb); // ← FCG021
// Bad — the whole list, forever (warning)
onValue(ref(db, 'messages'), cb); // ← FCG021
// Fix — bound it
onValue(query(ref(db, 'messages'), limitToLast(50)), cb);Covers the modular API (onValue, onChildAdded, get) and the compat API (.on('value'), .once()). limitToFirst/limitToLast/equalTo all satisfy it.
Unlike Firestore, an RTDB path gives no syntactic clue whether it addresses one node or a list, so the rule reads the last path segment: a dynamic ending (users/${uid}) is a single node and is left alone, while a static ending (messages, rooms/${id}/messages) is treated as a list. A path that isn't a literal at all is skipped — a false negative is much cheaper than flagging every single-node read in a codebase.
FCG022 — Realtime Database listener without cleanup error
The RTDB counterpart to FCG004. A listener opened in useEffect with no cleanup return survives unmount, so every remount stacks another listener on the same path — and each one re-downloads every subsequent change.
// Bad — never detached
useEffect(() => {
onValue(query(ref(db, 'messages'), limitToLast(20)), cb); // ← FCG022
}, []);
// Fix — return the unsubscribe
useEffect(() => {
const unsub = onValue(query(ref(db, 'messages'), limitToLast(20)), cb);
return () => unsub();
}, []);Recognises both the modular unsubscribe and the compat ref.off('value', cb) form. FCG004 only knows Firestore's onSnapshot, so before this rule the entire RTDB surface leaked silently; they're separate codes so you can suppress them independently. The .on(...) check is keyed to RTDB event names, so an EventEmitter or socket is never mistaken for a database listener.
FCG023 — Firestore pagination with offset() warning
Firestore charges for every document an offset skips, not just the ones returned. offset(1000).limit(20) bills 1020 reads to show 20 rows, and the cost grows with the page number — page 50 costs 50× page 1. Firebase's best practices are blunt: "Do not use offsets."
// Bad — page N costs N*20 extra reads
db.collection('orders').orderBy('createdAt').offset(n * 20).limit(20); // ← FCG023
// Fix — a cursor bills only what you receive
db.collection('orders').orderBy('createdAt').startAfter(lastDoc).limit(20);offset() is normal and cheap in SQL query builders like Knex, so this fires only when the chain to the left of .offset() contains a Firestore collection accessor.
FCG024 — minInstances bills idle instances warning
minInstances keeps instances warm so requests skip the cold start — and bills their CPU and memory the whole time they sit idle. It accrues at 3am with zero traffic, scales with both instance count and memory, and usually shows up as an unexplained "Idle Min-Instance CPU Allocation Time" line on the invoice.
// Warned — 5 instances billed around the clock, at 2GiB each
export const api = onRequest({ minInstances: 5, memory: '2GiB' }, handler); // ← FCG024
// setGlobalOptions is worse: this applies to EVERY function you deploy
setGlobalOptions({ minInstances: 1 }); // ← FCG024This is a legitimate setting rather than a bug, so it's a warning — the point is to surface the standing cost when it's introduced (usually by copy-paste) instead of on the bill. minInstances: 0 is the free default and is never flagged, and a variable value is left alone since it can't be evaluated statically.
FCG025 — Security rules grant public access error
allow read, write: if true lets anyone on the internet read and write your database. It's a security problem first, but it's also the fastest route to a runaway bill: every scraped document is a billed read, and security rules cannot rate-limit requests, so an open ruleset is an open tab.
// Bad — anyone, no sign-in
match /{document=**} {
allow read, write: if true; // ← FCG025
}
// Bad — Firebase's generated test-mode rules: public until the date, broken after
allow read, write: if request.time < timestamp.date(2027, 1, 15); // ← FCG025
// Fix
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}This scans .rules files, which aren't JavaScript — they get a separate text-based analysis path rather than the AST used by every other rule. if false, auth checks, role lookups via get(), and ordinary request.time comparisons are all left alone, and commented-out rules don't fire.
Because rules live at the project root rather than in src/, the setup wizard now writes gates that name them explicitly (npx costguard src/ firestore.rules storage.rules). A path that doesn't exist simply contributes no files. If you set up CostGuard before v0.9.0, add those filenames to your existing predeploy scripts and workflow — otherwise the gates will never see your rules.
Suppressing a violation
The rules are heuristics — when one flags code you've decided is fine, suppress it inline (ESLint-style). The suppression is honored everywhere: editor squiggles, the CLI, all three gates, and reports.
// costguard-disable-next-line FCG002
const snap = await getDocs(collection(db, 'smallLookupTable'));
const snap = await getDocs(collection(db, 'tiny')); // costguard-disable-line
// costguard-disable-file FCG014 ← at top of file: suppress FCG014 in the whole fileOmit the rule codes to suppress every rule on that line (// costguard-disable-next-line). The quickest way to add one: click the lightbulb on any squiggle → Suppress FCGxxx on this line.
Risk scoring
Each violation carries a point weight based on its real-world cost impact. Scores are capped at 100.
| Rule | Risk categories | Points | |---|---|---| | FCG001 Unstable deps | Cost + Memory Leak | 10 | | FCG002 Unbounded read | Cost + Scalability | 18 | | FCG003 Listener UI dep | Cost | 12 | | FCG004 No snapshot cleanup | Memory Leak | 22 | | FCG005 Read in loop | Cost + Scalability | 20 | | FCG006 No interval cleanup | Memory Leak | 18 | | FCG007 No event listener cleanup | Memory Leak | 15 | | FCG008 Fetch/axios in loop | Cost + Scalability | 20 | | FCG009 Read in render | Cost + Scalability | 16 | | FCG010 Compound render loop | Cost + Scalability + Memory Leak | 35 | | FCG011 Expensive op in high-freq handler | Cost + Scalability | 25 | | FCG012 Unbatched writes in loop | Cost + Scalability | 20 | | FCG013 Polling instead of onSnapshot | Cost + Scalability | 18 | | FCG014 Client-side filter | Cost + Scalability | 16 | | FCG015 Read-modify-write | Cost | 12 | | FCG016 Unexported Cloud Function | Cost + Scalability | 10 | | FCG017 Cloud Function in loop | Cost + Scalability | 25 | | FCG018 Missing useEffect deps array | Cost + Scalability | 30 | | FCG019 Trigger writes to its own document | Cost + Scalability | 40 | | FCG020 Gemini API key in client code | Cost | 35 | | FCG021 Unbounded Realtime Database read | Cost + Scalability | 18 | | FCG022 RTDB listener without cleanup | Memory Leak | 20 | | FCG023 offset() pagination | Cost + Scalability | 16 | | FCG024 minInstances idle billing | Cost | 12 | | FCG025 Security rules grant public access | Cost + Scalability | 35 |
Risk levels per category
| Points | Level | |---|---| | 0 | LOW | | 1 – 24 | MEDIUM | | 25+ | HIGH |
Deployment gates
Pre-commit hook
Blocks git commit if staged files contain HIGH risk violations.
CostGuard
────────────────────────────────────────────────────────────
src/invoices.tsx
Risk 58/100 | Cost: HIGH | Scalability: LOW | Memory Leak: HIGH
✗ Line 42 [FCG002] Unbounded Firestore read — add .limit()
✗ Line 71 [FCG004] onSnapshot missing cleanup return
────────────────────────────────────────────────────────────
2 violations in 1 file
✗ Blocked — fix HIGH risk violations before proceeding.Installed automatically by the setup wizard into your git hooks directory — .git/hooks/pre-commit by default, or wherever core.hooksPath points (so husky setups work too).
GitHub Actions PR gate
Runs on every pull request and posts a risk card comment. Fails the required check if HIGH risk violations are found, blocking the merge.

The workflow is written to .github/workflows/costguard.yml by the setup wizard. Requires costguard in devDependencies (added automatically).
Deploy gate
Blocks deploys when MEDIUM+ risk violations are found — wired in two places by the setup wizard:
firebase.jsonpredeploy hooks — added to every deploy target present (hosting, functions, firestore, storage, database), sofirebase deploy(andfirebase deploy --only <target>) is gated directly.package.jsonpredeploy script — runs automatically beforenpm run deploy.
firebase deploy # gated via firebase.json predeploy hooks
npm run deploy # gated via the npm predeploy scriptIf your project has no firebase.json, only the npm script gate is installed.
CLI
The analyzer is also available as a command-line tool for use in scripts and CI pipelines.
# Install (or use npx without installing)
npm install -D costguard
# Scan a directory
npx costguard src/
# Scan only staged files (for pre-commit hooks)
npx costguard --staged
# Output JSON for downstream tooling
npx costguard src/ --json
# GitHub Actions annotation format
npx costguard src/ --format=github
# Set the blocking threshold (default: HIGH)
npx costguard src/ --max-risk=MEDIUMExit codes: 0 = no violations at or above the --max-risk threshold · 1 = violations at or above the threshold (or invalid arguments)
Configuration
| Setting | Default | Description |
|---|---|---|
| costGuard.enable | true | Enable / disable all diagnostics |
Toggle from Settings → search costGuard, or add to your workspace settings.json:
{
"costGuard.enable": false
}Commands
| Command | Description |
|---|---|
| CostGuard: Setup | Re-run the feature setup wizard |
| CostGuard: Show Risk Score Details | Show full risk breakdown for the active file |
| CostGuard: Generate Report | Scan the workspace and write a markdown report (with a copy-paste AI fix prompt) to costguard/ |
| CostGuard: Copy Fix Prompt for AI | Copy the selected violation + surrounding code as an AI-ready prompt (also on the lightbulb menu) |
