Skip to content
← Blog UI/UX August 23, 2026

The Search Filter That Felt Fast But Wasn't: A UX Post-Mortem on Perceived Performance and State Chaos

9 min read
The Search Filter That Felt Fast But Wasn't: A UX Post-Mortem on Perceived Performance and State Chaos

I want to tell you about the search and filter feature I shipped on a client project about eight months ago — a product inventory dashboard for a mid-sized distributor here in Indonesia. On the surface it looked great. Smooth animations, a clean filter sidebar, instant-looking results. During the client demo, everyone nodded along. Within two weeks of going live, the client was messaging me asking why users kept "breaking" the page.

Nobody broke anything. The UI was just doing things users didn't expect, couldn't predict, and couldn't undo. That's a design failure, and it was entirely mine.

This is the post-mortem.

What the Feature Was Supposed to Do

The inventory dashboard had roughly 3,000 SKUs. Users needed to filter by category, supplier, stock status, and a price range slider, then combine those with a keyword search. Results updated dynamically — no page refresh. I built it with vanilla JS on the front-end, a PHP endpoint on the back, and MySQL doing the heavy lifting.

On paper: simple. In practice: I made four separate design mistakes that compounded each other badly.

Mistake #1: No Debounce on the Search Input (The Obvious One I Still Shipped)

I'll be honest — I knew about debouncing. I'd used it before. I just didn't wire it up properly in the final build because I was rushing to meet a deadline and it "felt fine" on my machine.

What this meant in production: every keystroke fired an XHR request to the PHP filter endpoint. A user typing "samsung" sent eight requests, each with a different partial string, each hitting MySQL with a LIKE '%s%', then LIKE '%sa%', and so on. The results would flicker. Sometimes an earlier, slower response would arrive after a faster one and overwrite the correct result. Classic race condition.

The fix was straightforward but I want to document the exact pattern I settled on, because the version I originally wrote was also wrong:

// Wrong: resets timer but doesn't cancel in-flight XHR
input.addEventListener('input', () => {
  setTimeout(() => fetchResults(), 350);
});

// Right: debounce + abort controller
let debounceTimer = null;
let activeRequest = null;

input.addEventListener('input', () => {
  clearTimeout(debounceTimer);
  if (activeRequest) activeRequest.abort();

  debounceTimer = setTimeout(() => {
    const controller = new AbortController();
    activeRequest = controller;

    fetch('/api/inventory/search?' + buildQueryString(), {
      signal: controller.signal
    })
    .then(res => res.json())
    .then(data => renderResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') handleError(err);
    });
  }, 350);
});

The critical addition is the AbortController. Without it, you're debouncing the trigger but not cancelling the in-flight requests. Race conditions persist. I wasted two hours debugging flickering results before I realised the timer wasn't the whole problem.

Mistake #2: Filter State Was Invisible to the Browser

This one hurt the most when I finally understood what was happening. My filters lived entirely in JavaScript memory. When a user filtered to "Category: Electronics, Supplier: PT Mitra Jaya, In Stock Only," found a product, clicked into it, then hit the browser back button — they returned to an empty, unfiltered list. Every single time.

Users weren't "breaking" the page. They were using the back button the way they use it everywhere else on the web, and my application was punishing them for it.

The solution is URL-persisted filter state. Every filter change should update the URL query string using history.pushState(), and on page load (including back-button navigation), the UI should read those parameters and restore the filter state before fetching results.

function syncFiltersToURL(filters) {
  const params = new URLSearchParams();
  if (filters.category) params.set('category', filters.category);
  if (filters.supplier) params.set('supplier', filters.supplier);
  if (filters.stock) params.set('stock', filters.stock);
  if (filters.query) params.set('q', filters.query);
  if (filters.priceMin) params.set('price_min', filters.priceMin);
  if (filters.priceMax) params.set('price_max', filters.priceMax);

  history.pushState(null, '', '?' + params.toString());
}

function readFiltersFromURL() {
  const params = new URLSearchParams(window.location.search);
  return {
    category: params.get('category') || '',
    supplier: params.get('supplier') || '',
    stock: params.get('stock') || '',
    query: params.get('q') || '',
    priceMin: params.get('price_min') || 0,
    priceMax: params.get('price_max') || 999999
  };
}

This also gave users something valuable they didn't know they wanted: shareable URLs. A warehouse manager could now filter to exactly the view they needed and send that link to a colleague. The client noticed this and actually mentioned it as a feature in their internal announcement. It wasn't a feature I planned. It was a fix for a bug that became a capability.

Mistake #3: The "Active Filters" State Was Invisible

Even after fixing the URL state, I had another problem: users couldn't tell what filters were currently active without manually scrolling back up to the filter sidebar. On smaller screens, the sidebar collapsed behind a toggle button, which made this even worse.

I'd seen "active filter chips" in other products — those little dismissible tags that show your current filters inline, near the results. I'd always thought of them as a nice-to-have visual flourish. After watching a session recording where a user clicked "In Stock Only," scrolled down, forgot they'd applied it, then spent three minutes convinced the product catalogue was "missing items," I now think of them as mandatory.

The implementation is simple. After every filter change, render a row of chips between the filter controls and the results list:

function renderActiveFilterChips(filters) {
  const container = document.getElementById('active-filters');
  container.innerHTML = '';

  Object.entries(filters).forEach(([key, value]) => {
    if (!value || value === 0) return;

    const chip = document.createElement('span');
    chip.className = 'filter-chip';
    chip.textContent = formatFilterLabel(key, value);

    const remove = document.createElement('button');
    remove.textContent = '×';
    remove.setAttribute('aria-label', 'Remove ' + formatFilterLabel(key, value));
    remove.addEventListener('click', () => clearFilter(key));

    chip.appendChild(remove);
    container.appendChild(chip);
  });

  const hasActive = Object.values(filters).some(v => v && v !== 0);
  if (hasActive) {
    const clearAll = document.createElement('button');
    clearAll.textContent = 'Clear all';
    clearAll.className = 'filter-chip filter-chip--clear';
    clearAll.addEventListener('click', clearAllFilters);
    container.appendChild(clearAll);
  }
}

Note the aria-label on the remove button. A button that just says "×" is not accessible. I've written about the kind of details that matter in accessibility-adjacent design decisions before, and this is exactly the sort of thing that gets missed under deadline pressure. It takes thirty seconds to add and makes a real difference for screen reader users.

Mistake #4: Zero Feedback During Loading

The PHP endpoint averaged around 180ms on the server. That sounds fast. But factor in network latency on a mobile connection, and real users were often waiting 400–600ms while staring at the previous results with no indication anything was happening. Some users clicked the filter again, thinking it hadn't registered. That fired another request, which arrived after the first, and they'd get a momentarily wrong result that then corrected itself. Confusing.

I added three things: a subtle loading overlay on the results grid, a disabled state on the filter controls during the fetch, and a result count that updates after each query. The count — "Showing 47 of 3,241 products" — turned out to be disproportionately useful. Users immediately understood their filter had done something and how much it had narrowed the set.

The loading overlay was a single semi-transparent div with pointer-events: none and a CSS fade transition. No library needed. I mention this because I see a lot of developers reaching for a spinner component from a UI kit when two lines of CSS would handle it. Overhead accumulates — keep your motion layer lean.

The Compounding Problem

Here's what made this painful: none of these four mistakes were catastrophic on their own. Flickering results from missing debounce? Annoying, but users adapt. Filter state lost on back navigation? Frustrating, but not a showstopper. No active filter visibility? Confusing, but users re-check the sidebar. No loading feedback? Slightly disorienting. But all four together, on a real user with real inventory queries, on a mobile connection from a warehouse floor? The experience felt genuinely broken. Users lost trust in the UI and started working around it — refreshing the page entirely rather than using filters, which defeated the whole purpose.

This is something I didn't fully internalise until this project: UX failures compound in ways that individual testing doesn't catch. I tested each feature in isolation and it passed. The system failed in combination.

What I Do Differently Now

After this post-mortem, I built a personal checklist that I run through before any search/filter feature goes to QA:

  • Debounce + AbortController: Both, always. Not one without the other.
  • URL state persistence: Default behaviour for any filter that's meaningful enough to share or return to.
  • Active filter chips: Visible inline, with individual dismiss and "clear all."
  • Result count: "Showing X of Y" after every filter change, even if X equals Y.
  • Loading state: Disable controls, show overlay, use CSS transitions not JS-heavy spinners.
  • Empty state: A specific, actionable message when filters return zero results — not a blank div.
  • Back button test: Manually tested every time. Navigate away, hit back, check filter state restored.
  • Mobile + throttled network: Test on Chrome DevTools with "Slow 3G" before calling it done.

The last two items seem obvious written down. They were obvious to me conceptually too. I just didn't do them before shipping. Now they're on the checklist, so I can't skip them under deadline pressure.

If you're building anything with live search or dynamic filtering, the backend performance is usually not your biggest UX problem. The gap between what your interface is doing and what the user thinks it's doing — that's where the real failures hide.


Frequently Asked Questions

Why does my search filter feel slow even when the query returns quickly?

Perceived slowness is usually a feedback problem, not a speed problem. If there's no loading indicator, no skeleton state, and no debounce on input, users feel lag even when the actual query is under 200ms. Fix the visual feedback first before optimizing the backend.

How should I handle URL state for search filters?

Persist filter state in the URL query string so users can share results, use the browser back button, and return to the same filtered view after navigating away. Not doing this is one of the most common oversights in filter UI design and one that directly hurts usability.

What's the right debounce delay for a live search input?

For most use cases, 300–400ms is the sweet spot. Under 200ms and you're still firing too many requests; over 500ms and the UI starts to feel sluggish. Test on a throttled network connection — what feels instant on your local machine may feel broken on a 3G mobile connection.

Share Twitter / X LinkedIn

Enjoyed this? Let's build something.

Start a project →
Keep reading

More articles

The Onboarding Flow That Silently Bled Users: A Post-Mortem on Progressive Disclosure Gone Wrong
UI/UX July 14, 2026
The Onboarding Flow That Silently Bled Users: A Post-Mortem on Progressive Disclosure Gone Wrong
I built an onboarding sequence that looked clean in Figma but quietly lost 60% of users before step three. Here's exactly what went wrong.
Read →
Error States That Don't Embarrass You: Redesigning Form Validation from Red Boxes to Clear Guidance
UI/UX July 2, 2026
Error States That Don't Embarrass You: Redesigning Form Validation from Red Boxes to Clear Guidance
Most form validation UX feels punitive. I rebuilt a 5-year-old product's error handling and cut support tickets by 40%. Here's what actually changed.
Read →
Dark Mode Is Not Just an Invert Filter: The Color Contrast Decisions That Actually Matter
UI/UX June 16, 2026
Dark Mode Is Not Just an Invert Filter: The Color Contrast Decisions That Actually Matter
Most dark modes are an afterthought — a CSS variable swap that ships looking like a cave. Here's what I've learned about color contrast, surface layering, and the decisions that separate a real dark mode from a lazy one.
Read →