DEV Community

Cover image for 0 reads to switch views: Firestore board filters in the browser
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

0 reads to switch views: Firestore board filters in the browser

Switching between saved views on a board in my tracker costs zero Firestore reads. The board's active tickets are loaded once and kept live, and every filter, sort, group-by and swimlane is a pure function run over them in the browser.

The constraint that forced it: Firestore cannot OR across fields, and every combination of filters you want to query needs its own composite index. A tracker with user-defined views over custom fields would need thousands of them.

I'm Panth, and I lead the software team at Oizom. This is from ticket-tracker, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D5.

The decision

Chosen One live query per board over its active tickets. Filter, sort, group-by and swimlanes are pure functions in packages/shared.
Rejected One Firestore query per view.
Why Firestore cannot OR across fields, cannot filter on range and inequality over two fields, and needs a composite index for every combination. User-defined views with custom fields would need thousands of indexes. In the browser, switching views is instant and costs no reads.
Ceiling About 5,000 active tickets per board, or about 5 MB.

A saved view is a few lines of data. A filter is a tree of and / or groups over leaves:

export type FilterGroup = { op: 'and' | 'or'; children: FilterNode[] };
export type FilterLeaf = { field: string; cmp: Cmp; value?: unknown };
export type FilterNode = FilterGroup | FilterLeaf;
Enter fullscreen mode Exit fullscreen mode

A leaf can compare any built-in field (stage, priority, assignee, tag, due date, state, creator, text, linked) or any custom field, with twelve comparisons from is to between. "Mine and high priority, or anything of Ann's" is this, straight from the tests:

const f: FilterNode = {
  op: 'or',
  children: [
    {
      op: 'and',
      children: [
        { field: 'assignee', cmp: 'is', value: 'me' },
        { field: 'priority', cmp: 'is', value: 'hi' },
      ],
    },
    { field: 'assignee', cmp: 'is', value: 'ann' },
  ],
};
Enter fullscreen mode Exit fullscreen mode

As a Firestore query, that OR spans two fields, and the tree can nest as deep as people like. As a function over an array, it's a recursive evalNode.

One function draws every view

The whole engine is one call:

applyView(tickets, view, board, ctx) → groups[]
Enter fullscreen mode Exit fullscreen mode

It does four things, in this order:

  1. Keep the ticket states the view asks for. The default is active only, so archived tickets leave every default view.
  2. Run the filter tree.
  3. Sort on several keys, ties broken by rank and then id, so every client gets the same order.
  4. Group, then group again inside each group for swimlanes.

Kanban, table, calendar and timeline all draw from what it returns. A ticket with two assignees or three tags appears in each of their groups. Swimlanes are computed over all visible tickets, so every column has the same rows.

me, today, thisWeek and overdue are tokens in the saved view, resolved when the function runs, against the viewer's id and time zone. A shared view called "Mine, due this week" means the same thing to everyone who opens it, each in their own week.

Because the function is pure, it's tested as one: the test file is 453 lines against 665 lines of engine, with a fixed clock (Tuesday 2026-09-22 in Kolkata) and a board with six custom field types.

The one query, and what it costs

The query that feeds the engine is about as simple as Firestore gets: the board's tickets where state == 'active', one listener per board. It needs no composite index.

Loading the whole board on every open would pay one read per card. So the board store is incremental. The first open runs the full query. After that, the device starts from Firestore's own IndexedDB cache of the last snapshot and asks only for tickets whose updatedAt is newer than the newest one it has seen. An unchanged board answers that with no documents, which Firestore bills as one read, whatever the board's size.

The delta query has no state filter, on purpose. A ticket that leaves the active set has to arrive so it can be dropped from the board, and a query for active tickets would never send it. The browser decides what to keep. One inequality on one field also means the delta needs no composite index.

A hard delete doesn't show up in any updatedAt > query, because the document is gone. So the watermark is trusted for 6 hours from when it was written. After that, the next open pays for one full query and agrees with the server again.

Where it stops

The decision writes down its own ceiling: about 5,000 active tickets, or about 5 MB, per board. Past that, the plan is to flag the board as large and run its views through Typesense, which can filter on every field, with paging. Archived tickets are never loaded by default, so the number that matters is open work, not history.

For a personal tool, where a board is the unit people share, 5,000 open tickets is far more than a board should hold. The trade is the right one for this product. It wouldn't be for a tracker that hosts a whole company on one board.

What I'd take from this for any Firestore app with user-built filters: if the data a screen needs fits on the device, query it once, keep it live, and do the rest in code you can unit test. Indexes are for the data that doesn't fit.

Where do your filters run: in the database, or over data you've already loaded?

Top comments (4)

Collapse
 
yahhi profile image
Valentina Koniukhova •

funny timing, i hit this exact question today in my own app (aimyear, a time tracker on firestore). v2 read everything through my server so the app and an ai agent always got the same numbers. then i counted: one "what's happening now" call was roughly 40 reads, on every start, stop or tick.

so today i flipped it. writes still go through the server, because it owns the rules (what counts as a stop, routines in each day's plan). reads come from listeners on the user's own docs, and stats and filters run on the device, like your boards. to stop the app and the agent drifting apart, the server's formulas run on shared examples and the app's tests have to match them.

stealing your 6h watermark for hard deletes. how do you show a write that hasn't come back yet: optimistic, or wait for the snapshot? i went with a spinner until the snapshot lands.

Collapse
 
panthpatel profile image
Panth Patel •

Same split I landed on: reads straight from Firestore, writes through the server, because that's where the rules live. In ticket-tracker every write is one of 52 commands, and the Firestore rules deny anything else.

To your question: optimistic. A command takes 150 to 400 ms where a direct write takes about 50, and a spinner on every edit would put that cost in front of the user all day. So the app shows the change straight away and the listener's snapshot confirms it. I wrote up that trade here: panth.vardayinitech.in/blog/writes...

Running the server's formulas on shared examples and making the app's tests match is a nice way to keep two copies honest. Who owns the examples: does the server generate them for the app, or do both read one fixture file?

Collapse
 
yahhi profile image
Valentina Koniukhova •

the server owns them. a script in the server repo runs the server's own functions (attention split, insights, comebacks) on inputs defined in that script, and writes inputs + answers to one json file in the app repo. the app test only reads it. change a formula on the server, rerun the script, and the app test stays red until the dart port catches up. the app never writes examples, so there's nothing for the two sides to disagree on.

read your commands post, same shape here: the app and the mcp server call the same server code, so there's one copy of the rules. on optimistic: fair, for edits all day in a ticket tracker i'd pick the same. mine is a timer with a handful of writes a day, and the server can refuse some of them: starting a session can come back "do today's main step first" with no session created. an optimistic start would show a running timer and then take it back, which feels worse than 300 ms of spinner. ticking a checkbox is where your argument hits, i might go optimistic there.

which made me curious: when a command comes back forbidden or conflict after the app already showed the change, what does the user see? does the snapshot just quietly put it back, or do you show something?

small correction to my comment: i said roughly 40 reads, measured it today, it's 59 for one "now" call. and 179 per timer start, now 2. wrote it up and credited your post at the top: worklore.dev/s/2026-10-23-how-i-re...

Thread Thread
 
panthpatel profile image
Panth Patel • • Edited

Server owns the examples, app only reads them, and the test stays red until the Dart port catches up. That's tidier than what I had in mind: there's one writer, so nothing to reconcile.

And you're right on the timer. If the server can say "do today's main step first" and refuse, an optimistic start lies for 300 ms and then takes it back. A spinner is the honest choice there.

On what the user sees when a command comes back forbidden or conflict after the app already showed it: I'd rather check what the app actually does on that path than answer from memory. I'll come back here with it.

179 reads down to 2 per timer start is a good number to have measured. Thanks for the credit in the write-up. Did the Dart port of the formulas turn up any case where the server's answer was the one that was wrong?