Engineering at Car & Classic · 4/4

One call instead of N: 90% fewer search requests

At Car & Classic, the search filter modal fired one request per filter group. One call, and a front end rebuilt around it, cut the modal’s requests by about 90%.

On this page

This is about my work at Car & Classic; my experience has the short version of the role and the codebase.

The system

Only a small part of the stack matters for this story: the Laravel app sits behind Cloudflare, and search runs on Elasticsearch. Each filter group’s counts, like how many cars there are per make or per price range, are an Elasticsearch aggregation.

The part of the system this post is aboutComponents: browser (client), Cloudflare, Laravel, Elasticsearch (database). Connections: browser → Cloudflare (HTTPS); Cloudflare → Laravel; Laravel → Elasticsearch (query).HTTPSquerybrowserInertia · VueCloudflareLaravelmodular monolithElasticsearchsearch · aggregations
Simplified. Browsers reach the Laravel app through Cloudflare, and the app asks Elasticsearch for the search results and the filter counts.

The problem: a chatty modal

The search filters live in a modal, with a group for each kind of filter. The modal used to fire one request per filter group to /api/v1/search/aggregations/*. Open the modal, and N requests went out, and each one became its own Elasticsearch aggregation.

Each request was small and quick on its own. Together, for a single feature, they added up to N times the round trips, headers and search queries a single answer would need.

The fix: one call

I refactored the modal to make a single call to /api/v1/search/filters, which returns every facet the modal needs in one response, computed in one go on the server.

BeforeAfter
Calls per modal openN, one per filter group1
Changeroughly 90% fewer requests

Each new response is larger, because it carries every facet at once, but it replaces N smaller ones. And every request that doesn’t happen is a query Elasticsearch doesn’t have to run.

We also talked about going further on the client: caching the filter responses with TanStack Query and persisting them in local storage, so the same search wouldn’t hit the API again on the next visit. In the end we left it out. One call per modal open was already a big enough cut, and a cache would have added its own questions, like when a stored response is too old to trust.

One request, many filters: the front end

Merging the requests was the easy half. The harder half was the front end: there are over twenty filters in that modal, and they all had to share one request without stepping on each other. I rebuilt it in three layers, with dependencies pointing one way only:

LayerWhat it does
ComponentsDraw one filter. They read from a composable and write to the store, nothing else.
ComposablesOne per mounted filter: join what’s chosen with the server’s response, and hand the component everything it needs to draw itself.
StoresWhat the user chose, and the single shared request. The only place state is written.
How the filters fit togetherComponents: URL (client), page props, selection store, facets request, search API, filter components, filter composable. Connections: URL → page props (parsed); page props → selection store (seed); selection store → facets request (params); facets request ↔ search API; facets request → filter composable (buckets); filter composable → filter components (reads); filter components → selection store (writes); filter components → URL (apply).parsedseedparamsbucketsreadswritesapplyURLsource of truthpage propsLaravel · Inertiaselection storeslices · the only writerfacets requestone, sharedsearch APIone callfilter componentsdraw · write choicesfilter composableone per filter · reads
A simplified view, without the full architecture. The URL seeds the selection store, the store drives one shared request, each filter reads its part through a composable, and applying the filters navigates to a new URL, which closes the loop.

A few decisions made it hold together:

  • The URL is the source of truth. Laravel parses it, Inertia passes it to the page, and the filter state is seeded from it every time the modal opens. Applying filters navigates to a new URL, which seeds the state again: one loop, no second copy of the truth to drift.
  • Each filter is a slice. A slice owns one filter’s state end to end: what it holds, what it adds to the query, how many filters it counts as, and how to reset. The store composes them and never reaches inside. Most slices come from three small factories (a range, a single choice, a list); only the ones that cascade, like choosing a make clearing the model below it, are written by hand.
  • Applied vs draft. Every slice keeps what the URL says apart from what the user is editing, so closing the modal without applying loses nothing, and reopening it on the same search doesn’t wipe a choice.
  • One request, shared. Twenty-plus mounted filters, one HTTP call. Changing a choice cancels the request in flight and sends one new one.
  • Range sliders don’t spam. While a price or mileage slider is being dragged, the value sent to the API holds still, and it’s released 500 ms after the last move. Otherwise every pixel would be a request.
  • Stale is not the same as loading. Pick France while the regions on screen are still the UK’s, and those regions are stale, not loading. Each filter knows which choices it depends on, and shows its skeleton while they’ve changed since the data it’s showing arrived. Before that, old regions could sit next to a spinner, or next to the new country.
  • A choice with no results is undone, not hidden. When a response no longer carries a chosen value, it’s removed from the state and the URL, so there’s never an active filter the user can’t see.
  • The type system keeps new filters honest. The per-filter tables are total records, not partial ones: when the backend adds a facet, TypeScript refuses to compile until someone decides what to do with it. Adding a filter is a slice, a couple of table rows, and nothing else.

The trade-offs

  • What it protected: performance, for the visitor and for the search cluster. One request instead of N means fewer round trips and far fewer aggregations for Elasticsearch to run.
  • What it cost: a bigger single response, and one shared fate: every filter now waits for the same answer, so one slow facet slows them all. The front end also got more complex, which is why it needed the layers, the slices and the tables above to stay maintainable and testable.
  • What it kept simple: the truth. The URL is still the only source of it, so sharing, bookmarking and the back button keep working without extra code.

Spot it in your own app

  • Open the Network tab, filter by Fetch/XHR, and open your search filters. Count the requests one interaction sends. More than one per interaction is worth a look.
  • Drag a price or range slider slowly and watch the same tab. If every step sends a request, it needs to hold its value while dragging.
  • Pick a filter, then change the one it depends on (a country, then a region). If the old options stay on screen next to a spinner, the component is treating stale data as loading.

Same lesson, other direction

This is the mirror image of an older project, where endpoints that did too much had to be split. The rule is the same both ways: one screen, one clear conversation with the API. Too big, and every request pays for data it doesn’t use. Too chatty, and every screen pays for round trips it doesn’t need.

Working in a twenty-year-old codebase

Most of the job isn’t greenfield. It’s changing a live marketplace that people buy and sell cars on every day, next to code that’s been around for two decades. Feature flags, observability and tests at every level are what make that safe: ship behind a flag, watch Sentry and Datadog, widen the rollout.

And it’s a team sport. The designers and the SEO team are part of most changes: a faster page is only a win if it still looks right and still ranks.

Thank you

A big thank you to Bruno Francisco. He’s a good friend and has mentored me a lot, and he’s a bit of a wizard: half the tricks he pulls out I’ve never managed to find written down anywhere. He’s the UI guy everyone would love to have on their team, and his site is worth a visit.