📂 Samrat Dutta Roy / 📝 Posts / From 3 Seconds to Instant: How URL-as-State Was Silently Killing Our Filter UX
Cover banner
🎮 Click / Space to Play Game

From 3 Seconds to Instant: How URL-as-State Was Silently Killing Our Filter UX

Date
May 26, 2026
Tags
react next.js URL state redux filter UX performance useSearchParams debounce replaceState SPA compound components frontend architecture web performance
Author
Samrat Dutta Roy
Status
Published

Direct answer: Using the URL as the single source of truth for a filter means every user interaction triggers a route update, a router reconciliation cycle, and derived state re-computation before the UI updates or an API call fires. Moving to a hybrid model — Redux for instant in-session state, URL updated via debounced replaceState in the background — reduces perceived filter latency from seconds to under 50ms while keeping URLs shareable and SEO intact.

Our product catalog page had a filter that felt broken. On a normal connection, selecting any filter option took around a second to respond. On slower mobile networks it stretched to 2–3 seconds. And if someone quickly clicked two or three filters in a row, the page would freeze on skeleton loaders — each click was triggering a new route transition that kept cancelling the previous in-flight API request.

The fix wasn’t a slow API. It wasn’t a missing CDN cache. It was an architectural decision that seemed reasonable on the surface: the filter had no local state at all. The URL was the only source of truth.


Two Separate Problems Worth Untangling

Before getting into the fix, it’s worth separating two distinct sources of latency that were both present — because they have different causes and different solutions.

Initial page load latency — the page used server-side rendering for the first batch of products for SEO purposes. This added time before the page was interactive. It’s a one-time cost on load, unrelated to filter interactions, and a deliberate trade-off for SEO.

Filter interaction latency — this was the main issue, and the one the fix actually addressed. Every filter click went through a full URL-driven cycle before anything visible happened.

Mixing these two up in your diagnosis leads you to the wrong fix. The SSR cost was intentional and acceptable. The interaction lag was not.


Why URL-as-State Made the Filter Slow

Here’s exactly what happened on every filter selection:

1. User clicks a checkbox
2. router.push() fires — URL query params update
3. Router processes the navigation / query param change
4. useEffect hooks subscribed to the route query detect the change
5. Filter state is derived (parsed) from the new URL
6. Second render — filter pills and checkboxes update
7. API call finally fires with the new filter params

The bottleneck wasn’t pushState itself — writing to browser history is fast. The slowness came from everything downstream that was subscribed to that URL change. Multiple useEffect hooks were waiting for the route query before parsing state, which triggered a second render. Only after that second render did the filter pills appear and the product API call go out — with zero debouncing.

So rapid clicks made things worse: each click triggered a new route transition that cancelled the previous in-flight API request, leaving users staring at skeleton loaders until the last transition settled.

In code, the pattern looked roughly like this:

// The problematic pattern — URL drives everything
const router = useRouter();
const { query } = router;

useEffect(() => {
  // Derived from URL — runs after every route change
  const filters = parseFiltersFromQuery(query);
  setActiveFilters(filters);
  fetchProducts(filters); // API call at the end of the chain
}, [query]);

const handleFilterChange = (key, value) => {
  // Every click goes through the router
  router.push({
    pathname: router.pathname,
    query: { ...query, [key]: value },
  });
};

Every click: URL write → router reconciliation → effect trigger → state derive → render → API call. A full round trip before the user sees any response.


The Fix: A Hybrid Source of Truth

I introduced a hybrid model with a clear rule: the URL is authoritative on initial page load; Redux is authoritative during the SPA session.

On first load, the filter reads from the URL — preserving deep-linking, shareability, and SEO-crawlable state. After hydration, a flag is set in app state to signal that the session is now “live.” From that point on, filter selections write directly to Redux, bypassing the URL entirely on the critical path.

The URL is still updated — but debounced, using history.replaceState (not pushState, to avoid polluting browser history on every filter tick), happening silently in the background after the user pauses.

// Redux slice — instant update
const handleFilterChange = (key, value) => {
  dispatch(setFilter({ key, value })); // <50ms — instant UI feedback
  debouncedApiCall(getState().filters); // 500ms debounce — batches rapid clicks
  debouncedUrlSync(getState().filters); // 500ms debounce — silent background sync
};

// URL sync — background only, not on the critical path
const debouncedUrlSync = debounce((filters) => {
  const params = serializeFiltersToQuery(filters);
  history.replaceState(null, '', `?${params}`); // No navigation event, no re-render
}, 500);

The result:

  • Filter pills and checkboxes update in under 50ms — instant visual feedback
  • Rapid clicks batch into a single API request instead of firing and cancelling repeatedly
  • The URL stays shareable and accurate — it just updates quietly, not on the critical path
  • SSR on first load still works — the URL is still read on initial hydration

On slow networks, filter selection went from a 2–3 second freeze to feeling instant. On fast connections, the improvement was still significant and immediately noticeable.


What Broke Immediately After Shipping

The win was real. The headache that followed was also real.

Because Redux was now holding session state, quick-filter links in our top navigation bar stopped working. These were simple anchor tags with filter query params pre-set — clicking “Summer Sale” would update the URL to ?category=summer-sale, which previously would trigger the filter to update. But now the page was only listening to Redux. The URL changed, nothing happened.

The page was listening to the wrong thing for that entry point.

The quick patch: we added a dedicated query param (?forceUrlSync=true) to those navbar links. The filter hook checks for this param on mount and, when present, treats the URL as authoritative — re-reading filter state from it and resetting the Redux source. Essentially a manual “resync” signal.

// On mount — check if this is a forced URL-authoritative load
useEffect(() => {
  const isExternalNavigation = query.forceUrlSync === 'true';
  const isFirstLoad = !sessionInitialized;

  if (isFirstLoad || isExternalNavigation) {
    const filters = parseFiltersFromQuery(query);
    dispatch(setFilters(filters));
    dispatch(markSessionInitialized());
  }
}, []);

It worked. But it was a band-aid — and an honest signal that introducing two sources of truth, even with a clear rule for each, creates surface area for exactly this kind of edge case.


The Compound Component Lesson (A Separate Regret)

After shipping the filter, another team wanted to use the same component for a different part of the product. Their design requirements were different — different pill styles, different panel layout, different toggle behaviour.

Because I’d built the filter as a single component with internal structure, they had no clean way to restyle parts of it. The only path was adding individual styling props for each internal element — pillClassName, panelStyle, triggerVariant — which doesn’t scale. Every new consumer with different needs means more props.

Had the filter been built using a compound component pattern, this would have been clean from the start. Compound components expose sub-components — Filter.Trigger, Filter.Panel, Filter.Pill — that share internal context but let each consumer compose and style them independently:

// Compound pattern — each consumer composes their own layout
<Filter defaultValues={activeFilters} onChange={handleChange}>
  <Filter.Trigger className="my-custom-trigger" />
  <Filter.Panel>
    <Filter.Pill className="my-custom-pill" />
  </Filter.Panel>
</Filter>

I learned about this pattern after building the flat version. It’s now how I think about any component that’s likely to be consumed by multiple teams with different design systems.


If I Were Rebuilding It Today

The hybrid approach worked, but it introduced two disjointed modes and the surface area for bugs like the navbar issue. The cleaner architecture would be:

Local component state as the single write source for user interactions. Filter clicks update local state immediately — no Redux, no URL, no intermediate step. Pills update in under 50ms.

Debounced replaceState as the outbound sync. After the user pauses, the current filter state is serialised and pushed to the URL via replaceState. No navigation event fires, no re-render triggered.

A popstate listener as the inbound sync. Browser Back/Forward and external links write to the URL — a popstate listener detects this and updates local state. This path should not be debounced — Back/Forward should feel instant.

An equality check as the circuit breaker. When local state changes trigger a replaceState, and popstate then fires, the equality check between the incoming URL params and the current local state prevents a re-render loop. If they’re semantically identical, nothing updates.

// Circuit breaker — prevent feedback loop
window.addEventListener('popstate', () => {
  const urlFilters = parseFiltersFromQuery(window.location.search);
  const currentFilters = filterStateRef.current;

  // Compare semantically (sorted, normalized) — not raw string
  if (!isEqual(normalizeFilters(urlFilters), normalizeFilters(currentFilters))) {
    setFilters(urlFilters); // Only update if genuinely different
  }
});

Two important details worth having in mind for this pattern:

The equality check must compare normalised params, not raw strings. ?color=red&size=m and ?size=m&color=red are semantically identical but string-different. Comparing the raw query string will give you false mismatches and unnecessary updates. Parse and sort both sides before comparing.

Only debounce the outbound sync (state → URL), not the inbound (URL → state). Back/Forward navigation should be instant. Debouncing a Back button feels broken.

This is more precisely described as bidirectional sync with a circuit breaker rather than a true single source of truth — because user clicks and browser Back/Forward are two independent write paths. But with the equality guard in place, it behaves like one source of truth for the common case, without the hard mode-switching of the hybrid approach.


The Takeaways

On URL-as-state: The URL is a great place to store filter state for shareability and SEO. It’s a poor place to drive UI updates from — because everything subscribed to it pays the full router reconciliation cost on every write. Keep the URL as a sync target, not a data source.

On two sources of truth: The hybrid approach worked, but every additional entry point that can write to filter state needs to know which source is authoritative at that moment. That surface area will bite you. Design for it explicitly rather than patching it after the fact.

On compound components: If a component will be consumed by more than one team, build it compound from the start. Retrofitting it after the fact means either a prop explosion or a breaking change.

On “single source of truth”: It’s a useful mental model, not always a literal technical truth. When the browser’s Back button and the user’s mouse clicks are both valid ways to update state, you have two write paths. The goal is not eliminating one — it’s making sure they don’t fight each other.


Frequently Asked Questions

Why does using the URL as state cause filter lag in React? URL-driven state means every user interaction triggers a router update, which goes through the router’s reconciliation cycle before useEffect hooks re-run to derive new state and fire API calls. The UI doesn’t update until this full cycle completes. On every click, the user waits for the router — not just the network.

What is the difference between history.pushState and history.replaceState for filter syncing? pushState adds a new entry to the browser history stack — every filter click would create a Back button entry, meaning users hitting Back would step through every individual filter selection. replaceState updates the current URL without creating a history entry, so the URL stays shareable and accurate without polluting browser history.

What is a hybrid URL and Redux state approach for filters? A hybrid approach treats the URL as authoritative only on initial page load — reading filter state from query params on hydration. For the rest of the SPA session, Redux (or local state) holds filter state and updates instantly on user interaction. The URL is synced in the background via debounced replaceState so it stays shareable without being in the critical path.

What is a compound component pattern in React? Compound components are a React pattern where a parent component exposes named sub-components — like Filter.Trigger, Filter.Panel, Filter.Pill — that share internal context but let consumers compose and style them independently. It avoids prop explosion when multiple teams need the same component with different visual requirements.

How do you prevent an infinite loop when syncing filter state bidirectionally with the URL? Use an equality check — sometimes called a circuit breaker — between the incoming URL state and the current local state before applying any update. If the two are semantically identical (compare normalised, sorted params — not raw query strings), skip the update. This prevents a replaceState from triggering a popstate that triggers another state update that triggers another replaceState.


Co-written with AI.