One document, not one query: a public board that costs the same at ten thousand visitors
Every marketplace has a screen that lists things: open jobs, available contractors, storefronts. The obvious implementation is a query over a collection. It is also the implementation that turns traffic into an invoice.
Firestore bills a read per document returned. A board showing two hundred listings costs two hundred reads. Not once — on every page view, from every visitor, including the ones who are not signed in and the ones who are crawlers. The cost of the feature scales with the number of people looking at it, which is precisely backwards: success is what makes it expensive.
The shape that fixes it
Public, shared, read-mostly data belongs in one document, keyed by owner:
publicIndex/{board}
{
"uid_a1b2": { "title": "Live-in caregiver, Glendale", "zip": "91206", "rate": 28,
"updated": 1756000000 },
"uid_c3d4": { ... },
...
}
The board is now a single getDoc(). One read whether the directory holds ten
entries or ten thousand, and whether one person is looking or ten thousand are. Filtering,
sorting and search happen in the browser over data it already has, so typing in a search box
costs nothing at all.
The objection, and the rule that answers it
One document that everyone writes to sounds like a disaster. It is not, because Security Rules can constrain a write to a single key:
match /publicIndex/{board} {
allow read: if true; // deliberately public
allow update: if request.auth != null
// a writer may only ever touch the key that is their own uid
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly([request.auth.uid])
&& request.resource.data[request.auth.uid].keys()
.hasOnly(['title','zip','rate','updated'])
&& request.resource.data[request.auth.uid].title is string
&& request.resource.data[request.auth.uid].title.size() < 120;
}
Each owner may edit their entry and nobody else's, the shape of an entry is validated, and the document as a whole is public by design. There is no bulk surface to scrape, because everything in it was already meant to be seen.
Know where the ceiling is, and say so in the code
A Firestore document is capped at 1 MiB. That is the number that decides when this design must change, so it belongs in a comment next to the write, not in someone's head:
// ~400 bytes per entry. Split by ZIP prefix at ~2,000 entries (~800 KiB),
// i.e. publicIndex/{board}_9 for 9xxxx. Not before: an empty shard is
// a read that returns nothing and a branch that can be wrong.
Sharding by locality rather than arbitrarily means a search still reads one document, because a person looking for care in Glendale never needed the Florida entries.
Write cost, and the part people get backwards
This trades reads for writes: every listing edit rewrites the document. That is the correct trade by a wide margin, because edits are rare and views are not. A listing might be updated twice a month and viewed a thousand times.
The same reasoning kills two habits worth naming. Never open a live listener on shared data
— onSnapshot bills a read per change per connected client, and it is the
fastest way to make a quiet product expensive. And never count by fetching: a counter
incremented on change costs one write, while getDocs() to learn a number costs one
read per document, every time anyone looks.
The test
Before shipping any feature, ask what it would cost if it went viral tomorrow. If the answer scales with views rather than with sign-ups, the design is wrong, and it is wrong now rather than later — because the version that gets expensive is the version that finally worked.
Written by Liana Grigory, also written Liana Grigoryan — entrepreneur, technology founder and U.S. Army veteran in Los Angeles. More at Writing.