Back to Blog
Build in Public

Day 4 of Run Explorer: Why Every Filter Needs Its Own Cache Key

I made two filtered requests finish out of order. The slower request did not replace the newer selection because each dataset has its own cache key, and the obsolete request receives an abort signal.

Run Explorer: see every step and find the failure
By Brian Shimkus 6 min read

The short version

Run Explorer no longer gives the component all 24 fixtures and asks it to filter them. The page sends its filters through an HTTP request and receives one page plus a total.

There is still no real backend. Mock Service Worker intercepts the request and returns controlled responses for normal, slow, empty, and failed scenarios. The React component uses the same request function it will use with the real API later.

TanStack Query stores each response under a key containing its filters:

queryKey: ['runs', filters]

That key is what keeps a failed-run page from appearing while the URL says succeeded.

Crossing the HTTP boundary

Before this change, App owned every fixture and called selectRuns directly. It filtered, sorted, and paginated inside the browser.

Now App calls getRuns. That function turns the filters into query parameters and makes a request:

export async function getRuns(filters, signal) {
  const params = new URLSearchParams(
    Object.entries(filters).map(([key, value]) => [
      key,
      String(value),
    ]),
  )

  return listSchema.parse(
    await request(`/api/runs?${params}`, { signal }),
  )
}

A request for failed runs can look like this:

/api/runs?q=&status=failed&sort=newest&page=1&scenario=normal

The response contains four fields:

  • items: the six runs on this page
  • total: the number of matching runs across every page
  • page: the current page number
  • pageSize: the maximum number of rows on one page

The mock handler still calls selectRuns, but the React component does not. It displays the page the response contains. Filtering that response again in the component would be a second pagination system layered on top of the first.

Why I mocked the network instead of the screen

The mock sits at the request boundary. The interface still calls fetch, waits for a response, validates JSON, and handles failure.

That lets me reproduce behavior that is easy to ignore when data is hardcoded directly into a component:

  • A normal response with matching runs
  • A deliberately slow response
  • A successful response with no matches
  • An HTTP 503 failure
  • An obsolete request finishing after a newer selection

Mock Service Worker is not a database. Its state resets after a full reload. It is a controlled substitute for the API while I build and test the frontend.

The startup file waits for the mock worker before mounting React. Without that wait, the first request could leave the browser before the worker is ready to intercept it.

Why fetch needs an explicit status check

A failed HTTP response does not automatically make fetch reject.

If the server returns HTTP 503, fetch still resolves to a response object. The response has ok: false, and the request function has to inspect it:

const response = await fetch(path, init)

if (!response.ok) {
  throw new ApiError(
    response.status,
    `Request failed (${response.status}). Try again.`,
  )
}

Only successful responses are passed to response.json() and then to the schema.

I removed that check and selected the error scenario. Instead of showing a useful 503 message, the app tried to parse the error body as a run list. The result was a schema error about missing fields.

The schema error was technically accurate, because the error body was not a run list. But it hid the boundary that actually failed. Restoring the status check made the screen say that the request failed with HTTP 503.

TypeScript is not network validation

TypeScript checks the code while I write it. It does not inspect JSON after the browser receives it.

The request ends with:

listSchema.parse(value)

That is a runtime check. It verifies that the received value has an array of runs, a nonnegative total, a positive page, and a positive page size.

Writing this instead would not perform that check:

value as RunList

A TypeScript assertion tells the editor to trust me. It does not prove the server returned the promised shape.

The schema proves shape, not truth. A response can contain a valid status that is stale or a valid duration that was measured incorrectly. Those require separate checks.

One dataset needs one cache key

TanStack Query stores requested data in a cache. A cache entry needs an identity.

The run list uses:

queryKey: ['runs', filters]

The filters contain the search text, status, sort order, page number, and demo scenario. Each of those values can change the response, so each belongs in the key.

These are different datasets:

['runs', { status: 'failed', page: 1 }]
['runs', { status: 'succeeded', page: 1 }]
['runs', { status: 'failed', page: 2 }]

If the key were only ['runs'], all three requests would share one cache entry. The screen could show failed runs under a succeeded filter because the cache would have no way to distinguish them.

The key describes the data, not the component that displays it.

The race I tested

I selected the slow scenario, changed the status to Failed, and immediately changed it to Succeeded.

That created overlapping work:

  1. A request started for failed runs.
  2. Before it finished, another request started for succeeded runs.
  3. The screen's current selection became succeeded.
  4. The failed request was now obsolete.

The final table showed 12 succeeded runs and included run-003. It did not show run-001, which is failed.

Two protections are involved.

First, the query key gives failed and succeeded separate cache entries. A result for one cannot masquerade as the other.

Second, TanStack Query gives the request function an AbortSignal. getRuns passes it into fetch. When the work becomes obsolete, that signal lets the browser stop waiting for it.

The protections are related, but they do different jobs. The key preserves identity. The signal avoids unnecessary obsolete work.

What cancellation does not prove

Aborting a browser request means this browser stopped waiting for the response.

It does not prove that a server rolled back work it already committed. It does not undo a mutation. It does not guarantee that an external model stopped generating or stopped incurring cost after accepting a request.

This lesson only cancels reads. Later mutations need their own success, failure, and rollback behavior.

Initial loading is different from refreshing

The interface distinguishes a first request from a request for data it already has.

On the first load, there are no rows to show:

Loading runs…

During a refresh of an existing cache entry, the old rows can stay visible while the page says:

Refreshing runs…

Blanking the table during every refresh would hide useful data and make the interface jump unnecessarily.

The cache treats a response as fresh for 10 seconds. That means TanStack Query may reuse it during that window. It does not mean the server's records stopped changing.

Which failures get retried

The query retries once when the error is a server failure with a status of 500 or higher.

It does not automatically retry:

  • A rejected token with status 401
  • A response that fails schema validation
  • A client error that repeated requests will not repair

Retrying a temporary 503 may help. Repeatedly requesting malformed JSON usually produces the same malformed JSON and hides a contract problem under extra traffic.

What I broke

I made two deliberate changes and restored both.

1. The missing status check

I removed the response.ok check. The HTTP 503 was replaced by a schema error because the app tried to parse the error body as successful data.

2. A key that was too short

I changed the query key from ['runs', filters] to ['runs']. Different filters then competed for the same cache entry. Increasing the freshness window could not fix that. It would only make the incorrectly named entry stay trusted for longer.

What I built on Day 4

The frontend now has:

  • A request function that serializes filters
  • Runtime validation at the network boundary
  • A mock API that controls normal, slow, empty, and failed responses
  • Separate loading, refreshing, empty, and error states
  • A Retry action after a failed request
  • Cache entries identified by all result-changing filters
  • Abort signals passed from TanStack Query to fetch
  • Tests for empty responses, HTTP failures, and rapid filter changes

The commit that introduced the boundary was:

feat: load run pages through a mock API

The mock lets me test request behavior before the Python API exists. It does not prove the future backend works.

Frequently asked questions

Why not keep filtering the fixtures directly in React?

That worked for the first 24 synthetic runs. The finished dashboard needs data owned by an API and database. Moving the boundary now lets the frontend practice real loading, failure, cancellation, and validation before the backend exists.

Why does the demo scenario belong in the query key?

The scenario changes the result. normal, slow, empty, and error do not represent the same request behavior. If an input changes the result, it belongs in the key.

Does stale time prevent race conditions?

No. Stale time controls how long cached data is considered fresh. It does not identify which filters produced that data. A wrong key stays wrong regardless of the freshness window.

Why validate JSON if the frontend and backend share a contract?

A server, proxy, deployment, or mock can return something unexpected. TypeScript cannot inspect a value arriving at runtime. Validation belongs where external data enters the app.

Does aborting fetch cancel work on the server?

Not necessarily. It tells the browser to stop waiting and may close the connection. Work already accepted by the server may continue.

Is Mock Service Worker the backend?

No. It intercepts requests and returns controlled responses in development and tests. It does not provide durable storage or prove that the real API behaves correctly.

Next up

The table can now request and cache one page. Next, clicking a run puts its id in the URL and opens a native dialog.

The dialog must have a name, trap keyboard interaction, close with Escape, and return focus to the button that opened it. Inside it, recorded step timings become a timeline without turning missing measurements into zero.

If you want to follow along, the repository is public and every step lands as a commit.

Related: Day 3 of Run Explorer: Why the Search Box and the URL Hold Different Text