Avoiding race conditions in JS

Race conditions?! Javascript?! It’s a real thing, I promise!

Let’s say you have some service that loads a User object from your API. You back the getById method with a cache because you’re a responsible developer, and you share a single instance across several locations in your app because you want those sweet, sweet cache hits. UserService might look something like this:

class UserService {
    private readonly userCache: Map<string, User> = new Map()

    async getById(id: string): Promise<User> {
        let user = this.userCache.get(id)
        if (user) return user

        user = await this.fetchById(id)
        this.userCache.set(id, user)
        return user
    }

    private async fetchById(id: string): Promise<User> {
        const response = await fetch(`/users?id=${id}`);
        return User.deserialize(response.body);
    }
}

Looks pretty good, right? Can you spot the bug?

It’s a little hard to trigger. Let’s say that you have two consumers of this api. Imagine the first fires of a request for user 10. Then, while that request is still in flight, another consumer fires off a request for that same user. Since the service is backed by a cache you’re happy to fire off requests willy-nilly.

What happens is there are two requests that end up in flight, wasting precious time. Here’s a visual:

So much wasted time! Luckily, the fix is pretty simple. Instead of caching the User, why don’t we cache the promise instead?

class UserService {
-   private readonly userCache: Map<string, User> = new Map()
+   private readonly userCache: Map<string, Promise<User>> = new Map()

    async getById(id: string): Promise<User> {
        let user = this.userCache.get(id)
        if (user) return user

-   user = await this.fetchById(id)
+   user = this.fetchById(id)
        this.userCache.set(id, user)
        return user
    }

    private async fetchById(id: string): Promise<User> {
        const response = await fetch(`/users?id=${id}`);
        return User.deserialize(response.body);
    }
}

That’s seriously all it takes. Due to implicit flattening of promises*, we don’t have to change anything else. Here’s how those requests look now that we’re caching the promises rather than the values:


async function random(): Promise<number> {
    return Math.random()
}

// is the same as ...

function random(): Promise<number> {
    return Promise.resolve(Math.random())
}