Preventing Duplicate API Requests Across Multiple Vue Components (Part 2)

Several months ago, I addressed a problem in my Vue application, which I shared through my article titled “Preventing Duplicate API Requests Across Multiple Vue Components”.

Whilst this provided a practical solution for a single API request, I quickly ran into a new problem of having to duplicate the code across multiple functions through multiple stores, to manage multiple API requests.

There had to be a better way to handle this.

In this article, I’ll walk you through the evolution of my initial solution and into the development of my RequestManager class. This new approach not only abstracts the original functionality, but improves it by enabling its re-use across multiple functions and stores, without adding unnecessary bulk to the code.

To keep this article focused on the request behaviour, I have neglected to mention or add the caching mechanisms around data retrieval. It’s worth noting that multiple requests for the same data would ideally be fetched from a local cache until the desired time-to-live has lapsed or the data has been manually invalidated.

The Original Problem: Duplicate API Requests

In the early stages of developing my Vue application, I encountered a fundamental problem that many developers face: maintaining the independence of Vue components while ensuring they have access to the same or similar datasets. This independence is crucial for creating modular, reusable components that can function effectively in various contexts without direct reliance on each other or a parent component to initiate API requests.

The ideal scenario allows each Vue component to access the required data autonomously, eliminating the dependency on a parent component to fetch this data. However, this autonomy led to a significant issue when multiple components, each needing the same data from an API, were used together in the same context. This resulted in simultaneous API requests for the same data.

This redundancy in API calls had several adverse effects on the application:

  • Performance Impact: Each additional, unnecessary request added to the load on both the client and server, potentially slowing down response times and degrading the user experience.

  • Redundant Data Retrieval: With each component fetching the same data independently, the system was effectively doing the same work multiple times over, leading to inefficiency and wasted processing power.

  • Wasted Resources: Beyond just the computational waste, this approach also squandered bandwidth, further impacting the application’s efficiency and performance.

Addressing this challenge required a solution that could preserve the components’ independence while mitigating the negative impacts of duplicate API requests.

The Original Solution: Promise Management

Finding a way to keep Vue components autonomous, yet efficient, led me to a smarter approach within my Pinia stores. Here’s how I tackled the problem:

import { defineStore } from 'pinia';
import { ref } from 'vue';
import apiClient from 'boot/axios';

export const useProfileStore = defineStore('profile', () => {

    const profile = ref(null);

    let loadProfileRequest = null;

    const loadProfile = async () => {
        if (loadProfileRequest) {
            return loadProfileRequest;
        }
        loadProfileRequest = new Promise(async (resolve, reject) => {
            try {
                profile.value = (await apiClient.get('/v1/user/profile')).data;
                resolve();
            }
            catch (err) {
                reject(err);
            }
            finally {
                loadProfileRequest = null;
            }
        });
        return loadProfileRequest;
    };

    return {
        profile,
        loadProfile
    };
});

This method is relies on the strategic handling of Promises:

  1. When data is requested by a component, the first step is to check for an existing pending Promise. This determines if a request is already in progress.

  2. If a pending Promise is found, this Promise is returned to the calling function. This avoids creating a new Promise and making another identical request.

  3. Once the initial request is completed, the Promise is either resolved with the data or rejected due to an error. This resolution or rejection is then shared with all functions that awaited this Promise.

  4. After the Promise is settled, the loadProfileRequest variable is cleared, allowing any further calls for this data to create a new request.

By managing Promises in this sequence, duplicate requests for the same data are effectively prevented, conserving bandwidth and processing resources while maintaining optimal application performance and user experience.

The Reusable Solution: Request Manager

The problem with my initial solution came to light as I needed to expand across multiple functions and stores. Each function within required its own loadXRequest variable alongside a repeated set of logic to manage the request Promise. This redundancy not only cluttered my stores with duplicate code but also escalated the maintenance burden. Any modifications to the request management logic required updates in several places, despite being small, drastically affecting the code’s re-usability and maintainability.

Centralising The Logic

To overcome this, I crafted a more scalable solution: the RequestManager class. My goal was to centralise HTTP request management, eliminate the duplication of request handling logic, and ensure that identical requests to the same URL and method were efficiently managed without unnecessary repetition.

Here's my initial iteration of moving the logic to a RequestManager class:

import { apiClient } from 'boot/axios';

class RequestManager {

    activeRequests = new Map();

    async call(method, url, data = {}, config = {}) {
        const key = `${method.toLowerCase()}:${url}`;
        if (this.activeRequests.has(key)) {
            return this.activeRequests.get(key);
        }
        const request = apiClient.request({ ...config, method, url, data }).finally(() => {
            this.activeRequests.delete(key);
        });
        this.activeRequests.set(key, request);
        return request;
    }
}

export default new RequestManager();

The key features of this RequestManager class are:

  • Use of a Map for Active Requests: This allows for efficient tracking and retrieval of ongoing requests. By using a combination of the HTTP method and URL as the key, it ensures that duplicate requests are easily identified and managed.

  • Centralised Request Handling: By centralising the logic in a single class, it significantly reduces code duplication across the application, making the codebase cleaner and easier to maintain.

  • Efficient Management of Duplicate Requests: When a request is already in progress for a given URL and method, subsequent requests wait for the existing Promise to resolve instead of initiating a new request. This reduces unnecessary network traffic and server load.

While this approach marked a significant step forward by streamlining API request management, there were still some side effects to overcome to fully maintain the original requirements, and satisfy the new ones.

Allowing Multiple Axios Clients + Adapting to SSR

To accommodate the application’s need to interact with various servers using multiple Axios clients, I needed to modify the RequestManager class to accept an AxiosInstance parameter, where previously it was importing a singleton instance.

With this modification, here's how the RequestManager looks:

class RequestManager {

    activeRequests = new Map();

    async call(client, method, url, data = {}, config = {}) {
        const key = `${method.toLowerCase()}:${url}`;
        if (this.activeRequests.has(key)) {
            return this.activeRequests.get(key);
        }
        const request = client.request({ ...config, method, url, data }).finally(() => {
            this.activeRequests.delete(key);
        });
        this.activeRequests.set(key, request);
        return request;
    }
}

export default new RequestManager();

This modification brought about several key advantages:

  • Multiple Axios Clients: By decoupling the Axios client from the RequestManager, it became possible to use different Axios instances as required. This is particularly useful in scenarios where different parts of an application need to interact with various APIs under different configurations.

  • SSR Compatibility: Introducing the capability to pass an AxiosInstance dynamically made the RequestManager compatible with server-side rendering environments. In SSR configurations, it's often necessary to create a new Axios instance per request to ensure that the server-rendered application can handle user-specific data securely and efficiently. This change ensures that the RequestManager can be integrated into SSR frameworks without any issues, enhancing its utility and flexibility.

By making the RequestManager more versatile and decoupled from a specific Axios instance, it has significantly improved its applicability across different application architectures, including those leveraging SSR.

Introducing Callbacks for Enhanced Control

While the RequestManager effectively prevented duplicate requests, a final hurdle remained: the calling function could still execute its post-request computations multiple times. This issue arose because, although the request was managed by the RequestManager, the calling functions merely awaited the response. Upon receiving it, all instances proceeded to process the response as needed, inadvertently leading to redundant computations and wasted resources.

To mitigate this, I refined the RequestManager to incorporate onSuccess and onError callbacks. This adjustment ensures that post-request processing is executed only once, when the Promise resolves or rejects, thereby streamlining the entire operation:

class RequestManager {

    activeRequests = new Map();

    async call(client, method, url, data = null, config = null, onSuccess = null, onError = null) {
        data ??= {};
        config ??= {};
        const key = `${method.toLowerCase()}:${url}`;
        if (this.activeRequests.has(key)) {
            return this.activeRequests.get(key).processed;
        }
        const processRequest = async (responsePromise) => {
            try {
                const requestResult = await responsePromise;
                const callbackResult = onSuccess && typeof onSuccess === 'function'
                    ? onSuccess(requestResult)
                    : undefined;
                return callbackResult !== undefined
                    ? callbackResult
                    : requestResult;
            }
            catch (err) {
                if (onError && typeof onError === 'function') {
                    onError(err);
                    return;
                }
                throw err;
            }
        };
        const requestPromise = client.request({ ...config, method, url, data }).finally(() => {
            this.activeRequests.delete(key);
        });
        const processedPromise = processRequest(requestPromise);
        this.activeRequests.set(key, { original: requestPromise, processed: processedPromise });
        return processedPromise;
    }
}

export default new RequestManager();

Key enhancements introduced by this final solution include:

  • Reduced Redundant Computations: By integrating onSuccess and onError callbacks, the solution ensures that post-request logic is executed only once, regardless of how many components or functions awaited the same API call. This significantly reduces unnecessary computations.

  • Streamlined Response Handling: When the request completes successfully, the onSuccess callback is invoked with the response data. This allows for centralised processing or state updates based on the API call's result.

  • Streamlined Error Handling: Similarly, if the request fails, the onError callback is triggered, allowing for a unified error handling mechanism across different parts of the application.

This comprehensive solution marks a significant improvement in managing API requests within Vue applications, addressing both the prevention of duplicate requests and the efficient execution of post-request logic.

Known Limitations

While the RequestManager class is a significant enhancement by preventing duplicate API requests and streamlining response handling, it's important to be mindful of certain limitations, especially as application requirements evolve.

In my specific use case, these limitations do not present a problem, however, some known limitations include:

  • Unified Callback Execution: The RequestManager executes only the onSuccess or onError callbacks of the first request in the case of identical requests made concurrently. This design choice optimises network and computational resources but may limit individual response handling flexibility. Though, you could simply bypass the use of the callbacks and handle each response independently.

  • State and Error Handling: Given that only the first set of callbacks is executed, managing state updates or performing granular error handling based on different parts of the application’s needs could be challenging. This approach assumes a uniform handling strategy for success and error responses.

  • Request Differentiation: The system identifies duplicate requests based solely on URL and method, potentially overlooking requests that differ in headers, query parameters, or POST bodies. Applications requiring differentiation based on these factors may need to extend the key generation logic.

  • Lifecycle Management: The centralised management of requests might complicate the handling of component lifecycles, such as cancelling requests when components unmount, especially in scenarios where multiple components depend on the outcome of a single request.

  • Singleton Design: The singleton design inherently ensures that only one instance of the RequestManager class exists throughout the application. This approach aligns with the library's goal to provide a unified mechanism for managing API requests, effectively preventing duplicate calls. However, this design choice might introduce constraints in scenarios where the need arises for multiple, independent request managers, particularly in complex applications requiring isolated request handling in different modules or sections.

I encourage you to explore the RequestManager in your projects, customise it to your needs, and contribute your insights to its development. It’s through such collaborative efforts that we can enhance our practices and tools, driving efficiency and innovation in web development.

If you’re interested in diving deeper, the RequestManager class is available here for your use:

Let’s continue to innovate and refine, making the process of web development smoother and more effective for all.

Happy coding!