Every runaway-bill call we take starts the same way: "we had a budget alert set." A budget alert is a notification. It does not stop anything. On the Blaze plan there is no spend ceiling, and the gap between "we got an email" and "someone woke up and looked" is where the four-figure invoices come from.
The controls that actually bound spend are per-service, and most of them are off by default. Here is the set we install on every engagement, in the order we install them.
First, know which line can run away
Not every service can hurt you at the same speed. Sort by how fast the meter can spin with no human involved:
- Cloud Functions / Cloud Run invocations. A recursive Firestore trigger — a function that writes to the collection it listens on — can emit tens of thousands of invocations per minute. Fastest path to a surprise, by an order of magnitude.
- Firestore reads. A client loop, a missing pagination limit, or a listener attached in a re-rendering component. At $0.06 per 100,000 reads, one misbehaving client at 200 reads/second for a day is about 17 million reads, roughly $10. One hundred clients doing it is $1,000.
- Storage egress and Hosting bandwidth. Someone hotlinks a 40 MB video from a public bucket.
- Outbound calls from functions to a metered third-party API. Firebase bills you for the invocation; the other vendor bills you for the rest.
Write that list down for your own app before configuring anything, because each item needs a different brake.
Budget alerts: still do it, but wire them to code
Set a Cloud Billing budget, and set the thresholds low enough to be actionable — 50%, 90%, 100%, and a forecasted-spend alert. Email at 90% of a month you have already spent is history, not a warning.
The part most teams skip is the Pub/Sub topic. A budget can publish each threshold event to Pub/Sub, and a function on that topic can act:
import { onMessagePublished } from 'firebase-functions/v2/pubsub';
import { CloudBillingClient } from '@google-cloud/billing';
export const budgetGuard = onMessagePublished('billing-alerts', async (event) => {
const data = event.data.message.json;
const spend = data.costAmount;
const cap = data.budgetAmount;
if (spend <= cap * 1.5) return; // alerts below this are for humans
const client = new CloudBillingClient();
const project = `projects/${process.env.GCLOUD_PROJECT}`;
await client.updateProjectBillingInfo({
name: project,
projectBillingInfo: { billingAccountName: '' }, // detaching billing stops the meter
});
});
Be clear-eyed about what that last call does: detaching the billing account takes the project down. Firestore stops serving, Hosting stops serving, functions stop. It is a circuit breaker, not a throttle, and it is the right thing only when the alternative is worse — a hobby project, a staging project, a preview environment. For production, set the threshold high, tell the on-call rotation it exists, and rehearse the reattach. We usually ship this function to non-production projects first, where it pays for itself the first time a load test loops.
Note the delivery characteristics too: budget data is derived from billing export and lags real spend by hours. This brake responds to yesterday's fire. The controls below respond in seconds, which is why they matter more.
Per-service ceilings, which do work in real time
Function instance limits. Gen 2 functions inherit Cloud Run's maxInstances. Set it per function, deliberately, on every function you deploy:
export const onInvoiceWrite = onDocumentWritten(
{ document: 'invoices/{id}', maxInstances: 10, concurrency: 20, timeoutSeconds: 60 },
async (event) => { /* ... */ }
);
Ten instances at twenty concurrent requests each is a known ceiling on both compute cost and downstream load. Without it the default is large enough to exhaust a database connection pool and a budget in the same minute. Excess traffic queues or fails rather than scaling into your invoice — an availability tradeoff you should make on purpose, per function.
Recursion guards. Any function that writes to the collection it triggers on needs an exit condition in the code, not just a hope. Compare before and after and return early when nothing you care about changed:
const before = event.data?.before.data();
const after = event.data?.after.data();
if (before?.total === after?.total) return; // our own write, ignore it
Also set maxInstances low on exactly these functions. The instance ceiling is what converts an infinite loop from an invoice into a backlog.
API quota overrides. In the Cloud console under APIs & Services → Quotas, you can lower the per-minute quota for the Firestore API and others below the default. This is the only control that caps client-driven reads directly. Set it to a multiple of your observed peak — look at the last 30 days in Metrics Explorer, then set the cap at three to five times p99, not at p99. When a client loop blows past it, the SDK gets RESOURCE_EXHAUSTED errors instead of you getting a bill. Degraded is better than unbounded.
App Check, enforced. Quotas cap volume; App Check caps who. An unenforced backend is a public API, and scrapers read at machine speed. Enforcement is a cost control at least as much as a security control.
A kill switch you can flip from your phone
The fastest brake is one that does not need a deploy. Put a Remote Config parameter in front of the expensive surfaces, and check it in the client and in your functions:
const rc = getRemoteConfig(app);
rc.defaultConfig = { feature_live_feed: true, feed_poll_seconds: 30 };
await fetchAndActivate(rc);
if (getValue(rc, 'feature_live_feed').asBoolean()) {
attachFeedListener();
}
Expensive surfaces worth a flag: the live feed, the search-as-you-type query, the analytics dashboard that aggregates over production, any polling interval. Flipping feature_live_feed to false, or raising feed_poll_seconds from 30 to 300, cuts the read line by an order of magnitude in the time it takes Remote Config to propagate — no release, no app-store review.
One caveat: Remote Config templates are themselves fetched, and a too-aggressive minimumFetchIntervalMillis adds its own traffic. Twelve hours is fine for feature gates; use a shorter interval only for the parameters you might need to flip in an incident.
Make it observable before you make it automatic
None of the above replaces a dashboard you actually look at. The minimum set: daily Firestore reads and writes, function invocations and instance count, and storage egress — each as a Cloud Monitoring chart with an alerting policy on rate of change, not on absolute value. A 10x day-over-day jump in reads is a signal hours before the spend threshold notices anything, because it reads from live metrics rather than billing export.
The working setup looks like this:
| Control | Stops | Latency |
|---|---|---|
| Budget alert email | Nothing | Hours |
| Billing-detach function | Everything | Hours |
maxInstances | Compute runaway | Immediate |
| API quota override | Read/write floods | Immediate |
| App Check enforcement | Unauthorized clients | Immediate |
| Remote Config flag | One feature | Minutes |
| Metrics alert on rate | Nothing, but warns early | Minutes |
Most teams have row one and nothing else. Rows three through six take an afternoon, and they are the difference between a bad hour and a bad month.