For an indie developer, there is a specific kind of adrenaline that hits when you receive a billing alert from a service that is supposed to be "free." A bill you cannot explain is more dangerous than a bill you can — because the thing you do not understand now is already scaling.

I build Atmos Football, a casual football stats tracker. We have a modest user base: about 20 active players and one game per week. Based on the Firebase pricing page, I should be living in the "Free Tier" forever. Two million invocations per month is a massive ceiling for a weekend hobby.

Yet, last month, the bill arrived: £0.10.

It is a laughably small amount. Most people would pay it and never look back. But in the world of cloud architecture, £0.10 is a warning shot. If you don't understand why you are being charged ten pence at 20 users, you definitely won't understand why you're being charged £50 when you hit 1,000 users.

After digging through the Google Cloud Billing console, I discovered that our "free" notifications were being throttled by two common architectural mistakes: a region mismatch and a hidden O(N) query. This is also why you should set the region before writing a single function. Migrating later costs you the cleanup time and the zombie risk. It is the architectural decision that is cheapest on day one and most expensive on day thirty.

1. The "Wait, Why Am I Being Charged?" Moment

In early March, we shipped a set of eight new Firestore-triggered functions to handle push notifications (reminders for signups, team locks, and voting). Almost immediately, the billing dashboard ticked up.

The breakdown was specific:

  • Cloud Run Functions CPU: £0.03
  • Cloud Firestore Reads: £0.07

My first reaction was confusion. I hadn't hit 2 million invocations. I hadn't even hit 2,000. Why was the CPU meter running?

The answer lies in how Firebase (and Google Cloud) has evolved. If you are using 2nd Gen Cloud Functions, you aren't actually running "Functions" in the legacy sense — you are running on Cloud Run. And Cloud Run has a very different set of "Free Tier" rules.

2. Understanding the Real Billing Model

The "2 Million Free Invocations" headline is a legacy artifact. For modern v2 functions, the real costs are CPU-seconds and Memory-seconds.

Here is the trap: the free tier for compute time only applies to specific US regions (us-central1, us-east1, etc.). If you host your functions elsewhere to be near your users, you might be paying from the very first millisecond.

Furthermore, 2nd Gen functions support concurrency — the ability for one container instance to handle multiple requests. However, if your function is slow because it's waiting on data from across the ocean, that container stays "active" and billable. If three users sign up at the exact same time on game night, Cloud Run might decide that the first container is "too busy" waiting for the database and spin up two more.

Suddenly, a single event cascades into three cold starts, three sets of CPU charges, and zero free-tier coverage.

3. Root Cause #1: The Region Mismatch

Atmos Football is a UK-based app. Naturally, our Firestore database lives in europe-west2 (London).

However, when you initialise Firebase Functions, the default region is us-central1 (Iowa). I had neglected to change this. Consequently, every time a game was updated in London, a trigger fired in Iowa. That trigger then had to reach back across the Atlantic to read the user's preferences from the London database before sending the notification.

The cost of the "Atlantic Gap":

  • Latency: A Firestore read that should take 5ms was taking 120ms due to the round-trip.
  • CPU idle time: I was paying for the CPU to sit there, doing nothing, just waiting for light to travel across the ocean.
  • The egress double-dip: Google Cloud charges for "Network Egress." Moving data from a London database to an Iowa function is a billable event. It's fractions of a penny, but it's a distinct SKU on the bill that adds up.

The fix is a single line in your function index:

import { setGlobalOptions } from "firebase-functions/v2";

setGlobalOptions({ region: 'europe-west2' });

⚠️ The "zombie function" warning

There is a massive gotcha here. When you run firebase deploy, Firebase creates the new functions in London, but it does not delete the old ones in Iowa. Because both sets of functions are looking for the same Firestore trigger, your users will receive duplicate notifications, and you will pay double. You must manually delete the old functions via the console or the CLI:

firebase functions:delete notificationHandler --region us-central1

4. Root Cause #2: The O(N) Token Query

The second half of our £0.07 Firestore bill came from a naive query pattern.

Every time we sent a notification (e.g. "Teams are locked!"), we called a helper function to find the right users. The code looked like this:

// THE NAIVE WAY: O(N)
async function _getSubscribedTokens(groupId) {
  // Read EVERY user in the system who has a push token
  const allUsersWithTokens = await db.collection('users').where('pushTokens', '!=', []).get();

  // Filter them in JavaScript to find who belongs to this group
  return allUsersWithTokens.docs
    .filter(doc => doc.data().groups.includes(groupId))
    .map(doc => doc.data().pushTokens);
}

In Big O terms, this is O(N), where N is the total number of users in the app.

When I had 5 users, reading the whole collection was fine. But with 50 users, a single "Teams Locked" notification for a 12-person game was reading 50 documents just to find the 12 relevant people.

The refactor: moving to O(K)

We already had the data we needed. The Group document contains a linkedPlayers map. By using that, we can target exactly who we need.

// THE TARGETED WAY: O(K)
async function _getTokensForGroup(groupId) {
  const groupDoc = await db.collection('groups').doc(groupId).get();
  const uids = Object.values(groupDoc.data().linkedPlayers); // Only the 12–15 relevant IDs

  // Only read the specific user docs for this game
  const userDocs = await Promise.all(uids.map(uid => db.collection('users').doc(uid).get()));
  return userDocs.map(doc => doc.data()?.pushTokens).filter(Boolean);
}

This reduced our reads from 50 to 16 per trigger. It also reduced the memory footprint of the function — loading 15 targeted documents into memory is much cheaper in terms of GiB-seconds than loading a massive collection and filtering it in-memory.

5. The Irony: We Already Had the Fix

The most frustrating part of this audit? I had already written the O(K) targeted logic for a signup reminder function I built the same week.

However, because I was in a rush to ship the other seven notification triggers, I copy-pasted an older utility helper that used the collection scan.

The lesson is simple: technical debt scales with your calling frequency. A sub-optimal helper function is a nuisance when called once a month; it is a financial drain when it's called 12 times per game week across 8 different triggers.

6. What We'd Do Differently

If you are an indie dev starting a Firebase project today:

  1. Set your region on day one. Do not rely on us-central1 if your data or users are elsewhere. Co-locate your logic with your data.
  2. Group your SKUs. Don't just look at the "Total Cost." Go to your Billing Report and group by "SKU." This is the only way to see if you are being hit by CPU idle time or excessive database reads.
  3. Design for the 1,000-user future. You don't need to over-engineer, but avoid collection().get() for any logic that fires on a frequent trigger.

7. The Numbers: Before and After

  • March (the wake-up call): £0.10 (£0.03 CPU + £0.07 reads)
  • April (the forecast): With co-located regions and targeted queries, we expect to be back at £0.00.

Our CPU time should drop by 90% now that the containers aren't waiting for trans-Atlantic data, and our Firestore reads for notifications have been cut by 70%.

Catching a mistake at £0.10 is a gift. It's a cheap lesson that ensures your app can actually afford to be successful.

Are you seeing "Cloud Run functions CPU" charges on your Firebase bill despite low traffic? It might be time to check your setGlobalOptions region.

— James