114 posts, 98 tools and 32 games on Firebase Blaze, billed at $0.00 a month. The exact rules: no Cloud Functions, no binaries in Storage, no per play writes.
Firebase's Blaze plan has a reputation problem. People hear "pay as you go", picture the horror stories — the recursive Cloud Function that wrote back into the collection that triggered it, the forgotten listener on a 400,000-document collection — and stay on Spark until Spark isn't enough. Then they upgrade and quietly brace for the email.
My portfolio site has been on Blaze for months. It serves 114 blog posts, 98 interactive browser tools and 32 browser games, with a Firestore-backed CMS behind an admin panel, images in Cloud Storage, and the whole thing on Firebase Hosting behind a custom domain. Last month's bill was $0.00. So was the month before, and the one before that. Not "basically free" — actually zero.
That isn't luck, and it isn't because the site is small. It's about six decisions, each of which said no to something Firebase was very happy to sell me. This post is all of them: what Blaze actually changes, why I don't deploy a single Cloud Function, where large binaries live instead of Storage, how 130 interactive pages generate zero writes, the Firestore read-amplification traps that quietly turn a content site into a metered one, and where this approach stops working.
The first misconception worth killing: upgrading to Blaze does not switch the free tier off. The no-cost quotas carry over unchanged. What Blaze removes is the hard ceiling that made Spark refuse work once you hit a limit; in its place you get a meter that starts at zero and bills only the usage above those same quotas.
Roughly what you get for nothing, per project, on either plan:
Check the current numbers before planning around them — Google moves them — but the shape is stable, and the shape is what matters. Note which resources meter per day (Firestore reads, Storage downloads, Hosting transfer) and which per month: daily quotas reset, so one bad day costs one bad day, not a month.
So the goal was never "avoid Firebase". It's stay under the line, and make crossing it structurally impossible rather than merely unlikely. Those are different engineering problems. The first is easy on a quiet Tuesday; the second is what matters when a tool link lands on Hacker News at 3am while you're asleep.
This is the rule I'm most rigid about, and it surprises people, because Functions are the most useful thing on the platform. The problem isn't the invocation price — two million a month is generous and I'd never come close. The problem is that Cloud Functions is the one Firebase product where a bug can bill you in a loop. A Firestore-triggered function that writes back into the collection it watches will re-trigger itself, as fast as the runtime can spawn instances. Every iteration is an invocation, plus a write, plus a read, plus CPU-seconds. You find out when the alert arrives, and by then the graph is already vertical.
Second, deploying Functions isn't free even when they never run. Each deploy builds a container image; those land in Artifact Registry, the build runs on Cloud Build, and both have modest no-cost allowances a few dozen deploys will chew through. Cents, not dollars — but "$0.00" and "$0.31" are different lines on an invoice.
Third, and most important: almost everything people reach for Functions to do, a content site can do somewhere else for free.
repository_dispatch, does it without a Firebase bill.The mental shift is that a build step is a free Cloud Function running on somebody else's meter. CI minutes on a public repository cost nothing, a build can't run away with itself the way a trigger can, and if it breaks it just breaks — it doesn't spend.
The honest cost is that I have no trusted server-side execution: no private API keys, no SSR, no webhook receivers. Those are real capabilities and I gave them up deliberately. If I needed one I'd deploy a function with maxInstances clamped low — and stop describing the site as free.
This one is pure arithmetic, and it's the rule that would otherwise have broken the bank first. My Flutter tools family ships real installers — APKs, macOS DMGs, Windows builds. Call an APK 45 MB, modest for a Flutter app with bundled assets. Cloud Storage gives you 1 GB/day of downloads at no cost. That's twenty-two downloads a day. Twenty-three people on a good day and the meter starts running.
Worse, download bandwidth is exactly the metric you want to grow. Every good thing that happens — a post that ranks, a tool that gets shared — pushes it up. A cost structure where success is the failure mode is a bad idea even while the numbers are small.
So no binary ever touches Cloud Storage. They live as GitHub Release assets and the site links to them. GitHub doesn't meter bandwidth on release assets for public repositories, the per-file limit is comfortably above anything I ship, and every release gets a permanent URL a download button can point at. Firestore stores the URL, version string and file size — a few hundred bytes per app instead of tens of megabytes plus egress. The bonus I didn't plan for: release assets give you versioned history free, so I never had to build a "previous versions" UI over Storage listings.
What's left in Storage is deliberately boring: cover images, a few screenshots, one PDF (my CV). Nothing else gets in, and that's enforced in the rules file rather than the upload UI — because the UI is the part an attacker skips.
rules_version = '2';service firebase.storage { match /b/{bucket}/o { function isAdmin() { return request.auth != null && request.auth.token.admin == true; } function underCap() { return request.resource.size < 10 * 1024 * 1024; } match /images/{allPaths=**} { allow read: if true; allow write: if isAdmin() && underCap() && request.resource.contentType.matches('image/.*'); } match /docs/{file} { allow read: if true; allow write: if isAdmin() && underCap() && request.resource.contentType == 'application/pdf'; } match /{allPaths=**} { allow read, write: if false; } }}Three things do real work. underCap() limits a single upload to 10 MB, so nobody — including me on a careless evening — can park a 400 MB screen recording in the bucket. The contentType match stops the bucket quietly becoming a general file host. And the final catch-all denies everything not explicitly matched above, the rule I'd keep if I could only keep one: default-deny is the only storage rule that survives you forgetting about a path.
Be precise about contentType, though: it comes from the client and can be spoofed, so it's a cost guard rather than a security guarantee. With the size cap and admin-only writes it does the job I need — making "accidentally expensive" impossible.
130 interactive pages — 98 tools and 32 games — and not one of them writes to Firestore. Not a score, not a session, not an analytics ping. That was a constraint from day one, and it's the biggest single reason the counters stay flat no matter what traffic does. A JSON formatter, a colour-contrast checker, a unit converter, a game of solitaire: none of these need a server. They need a pure function and some state. The function is Dart compiled to JavaScript, and the state lives in localStorage.
// Tools persist their own state locally. No network, no writes, no cost.final prefs = await SharedPreferences.getInstance();await prefs.setString('json_formatter.indent', '2');await prefs.setStringList('solitaire.best_times', best.take(10).toList());On Flutter web shared_preferences is backed by localStorage, so this is a synchronous browser API wearing a Dart shape. High scores, preferences, recent inputs, undo history, theme choice — all per-device, none per-account, and each of those decisions deletes a Firestore write from the design before it exists.
Consider the alternative. Say the games get a modest 3,000 plays a day and each writes a score document at the end. That's 3,000 writes, comfortably inside the 20,000/day quota — fine so far. Now add a leaderboard, because leaderboards are obviously good: every player who finishes reads the top twenty to see where they landed. That's 60,000 reads a day from a feature nobody asked for, and you're over the 50,000 line on a modest day. Read amplification is almost always a product decision wearing an engineering costume.
The trade is real: no cross-device sync, no global leaderboards, no history on a new laptop. For free browser tools that's the right call, and a better privacy story besides. If I ever add accounts it'll be for one feature with its own read budget, not a blanket "store everything and see".
Firestore bills per document read, and the ways to accidentally read a lot of documents are better disguised than people expect. These are the three that caught me.
collection('posts').snapshots() on a 114-document collection charges 114 reads the moment it attaches. After that it charges one read per changed document — which is cheap, and is exactly why listeners have a reputation for being efficient.
The trap is attachment, not steady state. Put that listener inside a StreamBuilder whose stream is constructed in build() and you pay 114 reads per rebuild. Leave it alive on a route the user left and it holds a connection open for a screen nobody is looking at. And if a listener stays disconnected long enough — Firestore documents a 30-minute threshold — reattaching bills like a brand new query, full result set again. A laptop lid closed over lunch is a full re-read on wake.
Three fixes, in order of how much they save:
get(), not snapshots(), unless the data changes while the user is watching it. Blog posts don't. A one-shot read is one billing event rather than an open-ended subscription..limit(). There's no such thing as a query you're sure will stay small forever. A limit is a promise you can keep; a collection's size isn't.dispose(). On the web an abandoned listener survives client-side navigation, because there's no page unload to clean up after you.Embarrassing when you find it in your own code:
// 114 document reads to display the number 114.final snap = await db.collection('posts').get();final total = snap.docs.length;Firestore has an aggregation query for this, billed at roughly one read per 1,000 index entries scanned rather than one per document:
final agg = await db.collection('posts').count().get();final total = agg.count; // one read, not 114For a counter that gets genuinely hot, don't even do that — keep a summary document, updated when the content changes. In my case that happens in the build step, so the update costs nothing.
One more while you're auditing: security rules that call get() or exists() are billed reads too. A rule checking an admins/{uid} document on every write is a read per write. It's the right pattern and I use it — but it belongs in your budget, not your blind spot.
The blog index is the site's highest-traffic route, and it needs a title, slug, date, excerpt, tag list and reading time for every post. Rendered naively that's 114 reads for one page view. At even 400 index views a day that's 45,600 reads — the entire daily quota, for one page.
So the index isn't a query. It's one document. A meta/posts_index doc holds an array of lightweight entries, regenerated by the CI job that publishes a post. Loading the list costs one read; opening a post costs one more, for the full body. A visitor who lands on the index and reads three posts costs four reads instead of a hundred and seventeen.
That's a denormalisation with the usual caveats: regenerate it or it goes stale, and Firestore's 1 MiB document ceiling caps how many entries it holds. At roughly 200 bytes an entry I have room for a few thousand posts, and when that runs out I'll shard by year. Both are manageable, and they buy a two-orders-of-magnitude cut in the exact metric I'm billed on.
Read-side caching helps too, and on web it's off until you ask:
// Web caches into IndexedDB, but only if you turn it on.FirebaseFirestore.instance.settings = const Settings(persistenceEnabled: true);// For data that tolerates staleness, skip the server:final doc = await ref.get(const GetOptions(source: Source.cache));
A cache hit is not a billed read — that sentence is most of the strategy.
Firebase Hosting gives you 360 MB/day of transfer at no cost. A cold Flutter web load is around 2.5 MB once CanvasKit, the bundle, fonts and first images are counted. That's roughly 140 cold visits a day before Hosting alone puts you over the line.
That sounds alarming. In practice it isn't, because almost nothing about a repeat visit should touch the network — but only if you configure it.
{ "hosting": { "public": "build/web", "headers": [ { "source": "/@(canvaskit|assets)/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }, { "source": "/@(index.html|flutter_bootstrap.js|main.dart.js)", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] }}The subtlety that trips people up: no-cache does not mean "don't cache". It means "cache it, but revalidate before reusing it". The browser sends an If-None-Match, Hosting answers 304 Not Modified, and a 304 has no body. You get instant deploys and near-zero egress on the entry point. To actually forbid storage you need no-store, and you almost never do.
The other half of that config matters just as much: do not blanket-immutable a Flutter web build. Flutter's default output filenames aren't content-hashed, so main.dart.js is the same URL after every deploy. Mark it immutable for a year and you've pinned some fraction of your users to a stale build with no way to reach them — the kind of bug you learn about from a confused email six weeks later. The safe set is the versioned CanvasKit path and the hashed asset manifest. Want the bundle immutable too? Hash the filename yourself in a post-build step first. Then it's earned.
Everything above is structural. Budget alerts catch the case where the structure has a hole I haven't found yet. I keep a Cloud Billing budget on the project set to $1, with alerts at 50%, 90% and 100%. A dollar is deliberately absurd, and that's the point — the threshold doesn't matter, because any email at all means something changed. But be clear-eyed: an alert is a notification, not a brake. Google doesn't stop serving your project when it fires. The canonical kill switch — a Pub/Sub topic feeding a function that calls the billing API to detach the account — is itself a Cloud Function, precisely the thing I refuse to deploy. So my real brake is the rules file. I'd rather make overspending impossible by construction than react to it fast.
I also run App Check in enforce mode on Firestore and Storage. It doesn't reduce legitimate usage by a single read, but it means the daily quota can only be spent by my own frontend rather than by a script someone points at my collections. That's the difference between a traffic spike and an abuse bill.
Now the honest part, because a post that says "Firebase is free" without drawing a boundary is useless. This works because the site is read-heavy, write-rare and stateless per user. Content changes when I publish; everything else is a small, bounded read. The moment any of the following is true, the model breaks and you should plan for a real invoice:
None of that makes Firebase a bad choice. Firestore is genuinely cheap for what it does, and $12 a month under a product that earns is fine. But "free" is an architecture, not a plan tier. If your shape is a content site plus client-side interactivity, that architecture is available to you, and the whole cost is a day of decisions made early instead of a migration made late.
storage.rules, not your upload UI, and end the file with a default-deny catch-all — the rule that survives a forgotten path.localStorage turns them into zero.Firebase isn't expensive. Unbounded reads, runaway triggers and unmetered egress are expensive, and Firebase is simply willing to sell you all three without complaint. Decide up front which your product genuinely needs, refuse the rest in the rules file rather than in a code review, and the invoice takes care of itself.