Search: Firestore's Own Real Query Limitations & Working Around Them

Personal Catalogue: React & Firebase

Chapter 6 · Search: Firestore's Own Real Query Limitations & Working Around Them

Chapter 1's own original question — "do I already own this?" — needs an answer across any item, matched by title or by whichever identifying field that item's own type actually has: author for a Book, artist for a Cd, director for a Dvd or Bluray. The MongoDB sibling answered this with one regex-powered $or query and two real crashes along the way. This chapter answers the same question with a genuinely different tool, because Firestore doesn't have the one the MongoDB sibling reached for at all.

The Real Limitation: No Substring Search, Anywhere

Firestore's own query engine supports equality, range comparisons (<, <=, >, >=) against an ordered field, array-contains, and in/not-in — and nothing else. There is no regex, no LIKE, and no built-in way to ask "does this field contain this substring anywhere." The MongoDB sibling's own $or: [{ title: regex }, { author: regex }, ...] has no Firestore equivalent to translate to — not a harder version of the same query, a genuinely different capability Firestore's query language doesn't have.

The Closest Native Tool: Prefix Range Queries

Firestore does support one real, commonly-used trick for "starts with" matching, exploiting the fact that strings are compared byte by byte: querying for everything between a prefix and that same prefix followed by a very high Unicode character finds every string starting with it.

import { collection, query, where, orderBy, getDocs } from "firebase/firestore"; async function searchByTitlePrefix(prefix) { const q = query( collection(db, "items"), orderBy("title"), where("title", ">=", prefix), where("title", "<=", prefix + "") ); const snapshot = await getDocs(q); return snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); }

Tried against a real book titled "Clean Code": searchByTitlePrefix("Clean") finds it — "Clean" is genuinely a prefix. searchByTitlePrefix("Code") finds nothing at all, even though "Code" is right there in the title, because it doesn't sit at the start of the string.

Three Real Limits, Not One
This trick is prefix-only (a mid-string term never matches), case-sensitive (Unicode ordering places lowercase and uppercase letters at different code points, so "clean" wouldn't correctly bound a query meant to match "Clean Code" without also storing a lowercase mirror field), and scoped to one field at a time — even combining four of these prefix filters across title/author/artist/director with Firestore's own newer or() query composition would still only catch a term sitting at the very start of one of those fields, never a term like "Martin" appearing partway through "Robert Martin."

The Real Answer at This App's Scale: Fetch, Then Filter

The MongoDB sibling's own honest scale note already applies here just as directly: at a few hundred to a few thousand items, a personal catalogue doesn't need a real search engine — it needs a search that works correctly. Since Chapter 3 already committed this course to no backend server of its own, the practical answer is the same idea the MongoDB sibling calls a "full collection scan," just run in the browser instead of on a database server: fetch every item, then filter with plain JavaScript string matching:

// search.js import { collection, getDocs } from "firebase/firestore"; import { db } from "./firebase"; export async function searchItems(rawQuery) { const term = rawQuery.trim().toLowerCase(); const snapshot = await getDocs(collection(db, "items")); const all = snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); if (!term) return all; return all.filter((item) => { const haystacks = [item.title, item.author, item.artist, item.director]; return haystacks.some((field) => field && field.toLowerCase().includes(term)); }); }

This one function does everything the MongoDB sibling's own $or query does — title, author, artist, or director, matched anywhere in the string, case-insensitively — genuinely more flexible than the prefix trick above, at the real cost of doing the matching after every document has already been fetched.

This Course Never Reproduces the MongoDB Sibling's Own Two Crashes — Structurally
The MongoDB sibling's own Chapter 6 found and fixed a route-ordering bug (a single-segment /search route shadowed by an earlier-registered /:id route) and an unescaped-regex crash (a raw parenthesis breaking new RegExp()). Neither can happen here — not because they were fixed, but because neither ingredient exists in this app's own architecture. Chapter 3 already decided this course has no Express-style router with path segments to order incorrectly, and searchItems() above never constructs a regex from user input at all, so there's no special-character syntax to ever need escaping. "Mission: Impossible (" is just a plain string being compared with .includes() — the parenthesis means nothing special to it.

The Real Cost the MongoDB Sibling Never Has to Think About

Fetching every document to search it isn't just slower at scale — it has a real, direct cost that a self-hosted MongoDB collection simply doesn't. Firestore bills by the number of documents actually read, and getDocs(collection(db, "items")) reads and counts every single document in the collection, on every search, regardless of how few actually match. A self-run MongoDB instance has no equivalent per-document billing at all — its own "full collection scan" cost is CPU time, not a metered line item.

A Real, Practical Mitigation: Debounce the Input
Searching on every single keystroke would multiply that real read cost by however many characters get typed. A short debounce — waiting for a brief pause in typing before actually calling searchItems() — is a genuine, worthwhile mitigation at this app's scale, built directly into the search component below.

A Small, Debounced Search Bar

// src/components/SearchBar.jsx import { useState, useEffect } from 'react'; import { searchItems } from '../search'; export default function SearchBar({ onResults }) { const [q, setQ] = useState(''); useEffect(() => { const timer = setTimeout(async () => { onResults(await searchItems(q)); }, 300); return () => clearTimeout(timer); // cancel the pending search if q changes again first }, [q]); return ( <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by title, author, artist, or director" /> ); }

Each keystroke resets the 300ms timer rather than immediately triggering a real Firestore read — only once typing genuinely pauses does searchItems() actually run, keeping the real, metered read cost tied to how often someone stops to look at results, not to how many characters they typed.

Trying It End to End

// Matches a Book by title await searchItems("Clean Code"); // Matches the same Book by author, even though the term never appears in the title await searchItems("Robert Martin"); // Matches a Dvd by director await searchItems("Kurosawa"); // A genuinely awkward title — no crash, no escaping needed, it's just a substring check await searchItems("Mission: Impossible ("); // A term matching nothing returns a clean empty array, not an error await searchItems("Nonexistent");

Hands-On Exercises

Exercise 1

Build searchItems() and confirm it returns the correct Book for a search matching only its author field, and returns a clean empty array (not an error) for a term matching nothing at all.

📄 View solution
Exercise 2

Build searchByTitlePrefix() and run it twice against a real book titled "Clean Code" — once with the query "Clean" and once with "Code" — recording both real results and explaining precisely why one succeeds and the other returns nothing.

📄 View solution
Exercise 3

Explain why this course's own search implementation is structurally immune to both real crashes the MongoDB sibling's own Chapter 6 found and fixed — the route-ordering bug and the unescaped-regex SyntaxError — tying your answer directly back to the architecture decision made in Chapter 3.

📄 View solution

Chapter 6 Quick Reference

  • The real limitation — Firestore has no substring/regex search of any kind; only equality, range, array-contains, and in/not-in
  • The native prefix trick — orderBy + a >= / <= range bounded by ""; prefix-only, case-sensitive, single-field
  • The real answer at this scale — fetch every document, filter client-side with plain string .includes(), matching the MongoDB sibling's own "full collection scan is fine here" logic one layer up
  • Structural immunity — no Express router to mis-order, no regex ever constructed from user input, so neither of the MongoDB sibling's own two real crashes can occur here at all
  • The real hidden cost — Firestore bills per document read; every search reads the entire collection regardless of match count, mitigated here with a 300ms debounce
  • Ceiling — a genuine, larger-scale catalogue would need a real third-party search service (an Algolia-style Firestore extension), the same honest ceiling the MongoDB sibling names for its own full-collection regex scan
  • Next chapter: The Catalogue List & Detail Views in React