An image depicting five pairs of robots, each pair illustrating the concept of caching in programming. In every pair, the first robot is fully detailed and complex, symbolizing the original source. The second robot in each pair is a partially complete, semi-transparent or skeletal version, representing a shallow, cached copy that lacks the full complexity of the original. The background is minimalist and futuristic, with a metallic and technological color scheme, emphasizing the contrast between the fully realized robots and their incomplete counterparts.

Be Aware of the `async` Keyword When Caching Promises

While experimenting with React's experimental use() hook, particularly for data loading, I encountered an intriguing challenge. My approach involved creating a fetch-wrapper with caching logic to efficiently utilize the use() hook. However, this seemingly simple solution led to an unexpected issue.

During component re-renders, I observed a brief but recurring activation of the suspense boundary, which manifested as a fleeting loading screen. Initially, I suspected this might be due to bugs in React's Suspense + use() implementation. But upon closer examination, it became evident that the root cause was a flaw in my caching logic, not in React itself. This mistake resulted in each promise being treated as new by the suspense system.

This experience highlighted the importance of a deep understanding of async functions and promises in JavaScript, especially when working with frameworks like React. Such insights are crucial for preventing unintended behaviors in applications. Below is a detailed exploration of this issue and the steps to resolve it.

What do you think will be the output of the following code?

const cache = new Map();
async function cachedFetch(url) {
  if (!cache.has(url)) {
    cache.set(url, fetch(url));
  }
  return cache.get(url);
}

console.log(cachedFetch("/test") === cachedFetch("/test"));

At first glance, you might think the output is true. However, the actual answer is false.

Debugging the Code: Unveiling the Truth

If we add some logging to the cachedFetch function:

const cache = new Map();
async function cachedFetch(url) {
  if (!cache.has(url)) {
    console.log("cache miss");
    cache.set(url, fetch(url));
  } else {
    console.log("cache hit");
  }
  return cache.get(url);
}

console.log(cachedFetch("/test") === cachedFetch("/test"));

The console output reveals, that underlying fetched data is correctly cached:

cache miss
cache hit
false

This behavior occurs because the cachedFetch function, marked with the async keyword, always returns a new promise. Consequently, consecutive calls to cachedFetch result in different promise objects.

The Implications: Unexpected Behavior in Your Code

Such behavior can lead to unforeseen issues. For example, if you put the result of such a cached call in the dependency array of React's useEffect(), the effect would trigger on every render, not just when the promise changes, leading to potential performance issues and bugs.

The Solution: Refactoring the Code

To resolve this, you can modify the cachedFetch function by removing the async keyword:

function cachedFetch(url) {
  if (!cache.has(url)) {
    cache.set(url, fetch(url));
  }
  return cache.get(url);
}

console.log(cachedFetch("/test") === cachedFetch("/test"));

Now, cachedFetch("/test") === cachedFetch("/test") returns true. The function consistently returns the same promise for repeated calls with the same argument, ensuring the expected caching behavior.

Best Practices: Avoid Pitfalls with Async Functions

When writing async functions, it's vital to understand their implications on your code's behavior. A good practice is to avoid async functions that don't contain an await statement. Tools like ESLint's require-await rule can help enforce this guideline, ensuring more predictable and efficient code.