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;
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' },
],
};
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[]
It does four things, in this order:
- Keep the ticket states the view asks for. The default is active only, so archived tickets leave every default view.
- Run the filter tree.
- Sort on several keys, ties broken by rank and then id, so every client gets the same order.
- 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?



