How to Control Concurrency in Frontend Development with High Concurrent Requests

Introduction

Recently, while developing mobile H5 applications, the homepage requires loading many resources, and a Lottie animation needs to request over 70 images. However, we encountered limitations on the number of concurrent requests in Android WebView, resulting in some image requests failing. Of course, image resources can be loaded lazily and preloaded to alleviate issues with resources not being loaded during animation playback.

How to Control Concurrency in Frontend Development with High Concurrent Requests

Similarly, business development may encounter scenarios requiring asynchronous requests to dozens of interfaces. If requests are made concurrently, the browser will limit the number of requests, which can also put pressure on the backend.

Scenario Description

Now, consider the following scenario:

Please implement a concurrent request function concurrencyRequest(urls, maxNum) with the following requirements:

  • Maximum concurrency maxNum
  • Each time a request returns, one slot is freed up for a new request
  • Once all requests are completed, the results should be printed in the order of the urls array (the request function can directly use fetch)

Initial implementation:

const preloadManger = (urls, maxCount = 5) => {
  let count = 0; // Counter -- used to control concurrency
  const createTask = () => {
    if (count < maxCount) {
      const url = urls.pop(); // Get value from the request array
      if (url) {
        // Regardless of whether the request is successful, taskFinish must be executed
        loader(url).finally(taskFinish);
        // Add the next request
        count++;
        createTask();
      }
    }
  };

  const taskFinish = () => {
    count--;
    createTask();
  };

  createTask();
};

// Perform asynchronous requests
const loader = async (url) => {
  const res = await fetch(url).then(res => res.json());
  console.log("res", res);
  return res;
}

const urls = [];
for (let i = 1; i <= 20; i++) {
    urls.push(`https://jsonplaceholder.typicode.com/todos/${i}`);
}

preloadManger(urls, 5);

Request status:

How to Control Concurrency in Frontend Development with High Concurrent Requests

As seen above, requests are made in groups of five. When a request returns, whether successful or failed, a new request is taken from the request array to supplement it.

Design Approach

We can consider using a queue to request a large number of interfaces.

The approach is as follows:

Assuming the maximum concurrency is maxNum=5, the interfaces are defined with numbers. When a request returns from the queue pool, a new interface is added to the pool for requesting, continuing until the last request is completed.

How to Control Concurrency in Frontend Development with High Concurrent Requests

Of course, to ensure the robustness of the program, some edge cases need to be considered:

  • When the initial request array urls has a length of 0, the result array results will be an empty array.
  • If the maximum concurrency maxNums is greater than the length of urls, the number of requests will equal the length of urls.
  • A counter count needs to be defined to determine if all requests are completed.
  • Regardless of whether the request is successful or not, the result should be stored in the result array results.
  • The order of the result array results should match that of the urls array for easy access.

Code Implementation

In the previous initial implementation, while it meets basic requirements, it does not consider some edge cases. Therefore, it needs to be re-implemented based on the design approach above:

// Concurrent request function
const concurrencyRequest = (urls, maxNum) => {
    return new Promise((resolve) => {
        if (urls.length === 0) {
            resolve([]);
            return;
        }
        const results = [];
        let index = 0; // Index of the next request
        let count = 0; // Number of current completed requests

        // Send requests
        async function request() {
            if (index === urls.length) return;
            const i = index; // Save index to ensure result matches urls
            const url = urls[index];
            index++;
            console.log(url);
            try {
                const resp = await fetch(url);
                // Add resp to results
                results[i] = resp;
            } catch (err) {
                // Add err to results
                results[i] = err;
            } finally {
                count++;
                // Check if all requests are completed
                if (count === urls.length) {
                    console.log('Completed');
                    resolve(results);
                }
                request();
            }
        }

        // Call with the minimum of maxNum and urls.length
        const times = Math.min(maxNum, urls.length);
        for (let i = 0; i < times; i++) {
            request();
        }
    });
};

Test code:

const urls = [];
for (let i = 1; i <= 20; i++) {
    urls.push(`https://jsonplaceholder.typicode.com/todos/${i}`);
}
concurrencyRequest(urls, 5).then(res => {
    console.log(res);
});

Request results:

How to Control Concurrency in Frontend Development with High Concurrent Requests

The above code basically meets the requirements for frontend concurrent requests. In production, there are many libraries that are already encapsulated and can be used directly, such as: p-limit【https://github.com/sindresorhus/p-limit】

Reading p-limit Source Code

import Queue from 'yocto-queue';
import {AsyncResource} from '#async_hooks';

export default function pLimit(concurrency) {
 // Check if this parameter is a positive integer greater than 0, if not, throw an error
 if (
  !((Number.isInteger(concurrency)
  || concurrency === Number.POSITIVE_INFINITY)
  && concurrency > 0)
 ) {
  throw new TypeError('Expected `concurrency` to be a number from 1 and up');
 }

 // Create a queue -- for storing requests
 const queue = new Queue();
 // Counter
 let activeCount = 0;

 // Function to handle concurrency
 const next = () => {
  activeCount--;

  if (queue.size > 0) {
   // queue.dequeue() can be understood as [].shift(), taking out the first task in the queue, since it is confirmed to be a function, it can be executed directly;
   queue.dequeue()();
  }
 };

 // The run function is used to execute asynchronous concurrent tasks
 const run = async (function_, resolve, arguments_) => {
  // activeCount increases by 1, indicating that the current concurrency increases by 1
  activeCount++;

  // Execute the passed asynchronous function, assigning the result to result, note: now result is a Promise in a pending state
  const result = (async () => function_(...arguments_))();

  // The resolve function is the resolve function of the Promise returned in the enqueue function
  resolve(result);

  // Wait for the result's state to change, using try...catch because the result may throw an exception, so it needs to be caught;
  try {
   await result;
  } catch {}

  next();
 };

 // Add the run function to the request queue
 const enqueue = (function_, resolve, arguments_) => {
  queue.enqueue(
   // Bind the run function to AsyncResource, not executed immediately, so a bind method is added
   AsyncResource.bind(run.bind(undefined, function_, resolve, arguments_)),
  );

  // Immediately execute an asynchronous function, waiting for the next microtask (note: because activeCount is updated asynchronously, it needs to wait for the next microtask to execute to get the new value)
  (async () => {
   // This function needs to wait until the next microtask before comparing
   // `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
   // when the run function is dequeued and called. The comparison in the if-statement
   // needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
   await Promise.resolve();

   // Check if activeCount is less than concurrency and there are tasks in the queue, if so, the tasks in the queue will be executed
   if (activeCount < concurrency && queue.size > 0) {
    // Note: queue.dequeue()() executes the run function
    queue.dequeue()();
   }
  })();
 };

 // Accept a function fn and parameters args, then return a Promise, executing the dequeue operation
 const generator = (function_, ...arguments_) => new Promise(resolve => {
  enqueue(function_, resolve, arguments_);
 });

 // Expose the current concurrency and the number of tasks in the queue, and manually clear the queue
 Object.defineProperties(generator, {
  // Current concurrency
  activeCount: {
   get: () => activeCount,
  },
  // Number of tasks in the queue
  pendingCount: {
   get: () => queue.size,
  },
  // Clear the queue
  clearQueue: {
   value() {
    queue.clear();
   },
  },
 });

 return generator;
}

The entire library consists of only 71 lines of code, and it imports the yocto-queue library, which is a micro queue data structure.

Handwritten Source Code

When writing the source code manually, a simple implementation can be done using arrays:

class PLimit {
    constructor(concurrency) {
        this.concurrency = concurrency;
        this.activeCount = 0;
        this.queue = [];
        
        return (fn, ...args) => {
            return new Promise(resolve => {
               this.enqueue(fn, resolve, args);
            });
        };
    }
    
    enqueue(fn, resolve, args) {
        this.queue.push(this.run.bind(this, fn, resolve, args));

        (async () => {
            await Promise.resolve();
            if (this.activeCount < this.concurrency && this.queue.length > 0) {
                this.queue.shift()();
            }
        })();
    }
    
    async run(fn, resolve, args) {
        this.activeCount++;

        const result = (async () => fn(...args))();

        resolve(result);

        try {
            await result;
        } catch {
        }

        this.next();
    }
    
    next() {
        this.activeCount--;

        if (this.queue.length > 0) {
            this.queue.shift()();
        }
    }
}

Conclusion

In this article, we briefly introduced why concurrent requests are necessary, explained the design approach for implementing concurrent requests using a request pool queue, and provided a brief implementation code.

Additionally, we analyzed the source code of p-limit and wrote a simple source code implementation using arrays to meet the requirements.

References

  • [Source Code Reading] How to Control Concurrency in High Concurrent Requests https://juejin.cn/post/7179220832575717435?searchId=20240430092814392DC2208C545E691A26

  • Frontend Implementation of Concurrent Control for Network Requests https://mp.weixin.qq.com/s/9uq2SqkcMSSWjks0x7RQJg

  • About Frontend: How to Implement Control of Concurrent Request Quantity? https://juejin.cn/post/7163522138698153997

Leave a Comment