DEV Community

Cover image for Fixing a Search Race Condition in npmx
L Anil Kumar Singha
L Anil Kumar Singha

Posted on Edited on

Fixing a Search Race Condition in npmx

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

npmx is a modern browser for the npm registry. It provides package search, version timelines, dependency information, comparisons, and other tools for exploring npm packages.

I've been contributing to npmx recently, mostly around search behavior and edge cases. One of those contributions started with a particularly interesting symptom:

The search failed the first time, but worked after a refresh.

Bug Fix or Performance Improvement

The bug was reported in issue #2617.

The reproduction was quite specific.

With instant search disabled and a saved page size of 50 or higher:

  1. Open the search page.
  2. Search for a package.
  3. No package results appear.
  4. The organization suggestion can still appear.
  5. Refresh the exact same page.
  6. The package results suddenly show up.

Same query. Same page size. Same application.

The refresh behavior was the interesting part.

If the packages didn't exist, refreshing shouldn't make them appear. And if the search request itself was fundamentally broken, I'd expect the second attempt to fail too.

That pointed away from the search data itself and toward what was happening during the initialization of the page.

Following the timing

npmx stores the user's preferred search page size locally.

On the first navigation to the search page, however, the initial search could begin before that saved preference had finished loading.

The sequence looked roughly like this:

Search page opens
        ↓
Initial search starts
(page size = 25)
        ↓
Saved preference loads
        ↓
Page size changes to 50
        ↓
fetchMore() runs
        ↓
Initial search is still pending
Enter fullscreen mode Exit fullscreen mode

Individually, none of these operations were wrong.

The problem was when they happened relative to each other.

fetchMore() could run while the initial Algolia search was still pending.

At that moment, the search result hadn't arrived yet. fetchMore() could therefore read the temporary empty response and store that value in the search cache.

Then the original search completed with the real package results.

But it was too late.

The empty cached value could take priority, leaving the UI showing no packages even though the search had successfully returned them.

That also explained why refreshing appeared to magically fix the problem.

It wasn't magic.

The initialization timing had changed.

Code

The fix is in npmx-dev/npmx.dev#3109.

Instead of allowing fetchMore() to continue while the initial search is unresolved, it now checks whether that search is still pending and waits for it:

if (asyncData.status.value === 'pending') {
  await asyncData.refresh({ dedupe: 'defer' })
}
Enter fullscreen mode Exit fullscreen mode

The important detail here is:

dedupe: 'defer'
Enter fullscreen mode Exit fullscreen mode

I didn't want fetchMore() to cancel the search that was already running and replace it with another request.

It needed to join the existing in-flight request.

So the flow changed from something like:

Initial search ────────────────► package results
        │
        └── fetchMore()
                ↓
          reads empty state
                ↓
          empty value cached
Enter fullscreen mode Exit fullscreen mode

to:

Initial search ────────────────► package results
                                      ↓
                               fetchMore() continues
                                      ↓
                               results preserved
Enter fullscreen mode Exit fullscreen mode

It's a small change in code, but it changes the ordering guarantee between the two asynchronous operations.

My Improvements

The main fix was making fetchMore() wait for the pending initial search before consuming its result.

But a race-condition fix also needs a test that can reliably reproduce the timing that caused it.

Otherwise, the test might pass simply because the operations happened in a convenient order that particular time.

So I added a regression test that deliberately recreates the problematic sequence.

The test:

  1. Starts the initial Algolia search.
  2. Keeps that request pending.
  3. Changes the requested page size.
  4. Triggers the additional result-loading path.
  5. Resolves the original search.
  6. Verifies that the completed package results are preserved.

That was important because the test isn't relying on the race condition happening naturally.

It controls the timing.

After the fix, users with a saved page size of 50 or higher get their package results on the first search without needing to refresh the page.

What I took away from this bug

What made this issue interesting wasn't the amount of code required to fix it.

It was the symptom:

It doesn't work the first time, but refreshing fixes it.

That can be a useful debugging clue.

When persisted client state, initialization, caching, and asynchronous requests interact, a refresh can change the order in which those operations happen.

In this case, the individual pieces were working.

The initial search worked.

The saved preference worked.

fetchMore() worked.

The problem only appeared when they ran in a particular order.

The final fix wasn't to make the search faster or add another request.

It was simply to make one asynchronous operation respect another operation that was already in progress.

Sometimes fixing a race condition is less about doing more work and more about making sure the work you already have happens in the right order.


If you're interested in more of my open-source work and engineering projects, you can explore my portfolio or follow my work on GitHub.

Top comments (0)