Concurrency Throttling in Node.js

Concurrency Throttling in Node.js

One of the first things you learn when developing microservices is that they are generally expected to run under strict resource constraints. Since microservices are commonly deployed with many concurrent instances in operation, a core expectation is that each instance manage its own resource consumption intelligently. Typically these hard limits are governed by the pre-allocated CPU and memory of the virtual machine within which the microservice(s) will run.

A robust microservice, then, must be configurable (i.e to use more or less memory) and must have built-in throttling mechanisms preventing overloading of the CPU or exhaustion of all available memory of the host container.

Other constraints maybe implicit: consider a file or media conversion microservice, where a single incoming request may require the conversion of thousands of files. Even if the host box's memory or CPU is not used up, having too many file descriptors open (an OS-level constraint) can cause unexpected errors like this little gem:

Error: EMFILE, too many open files  

Given such constraints exist, I'd like to outline some commonly-used strategies to throttle and manage resource usage:

1. Use Node.js Streams

Streams are one of Node.js' most underrated and under-utilized features. Streams allow the processing of large blocks of data in-transit, i.e processing a file while it is being being read or uploaded, rather than all at once after loading into memory. Rather than try to explain any further, I will direct you to a great resource for learning about streams: The Stream Handbook.

2. Leverage key Node.js libraries:

Keeping oneself aware of key Node.js libraries and modules is invaluable. For instance, one quick way to deal with the open file descriptors problem is to use something like graceful-fs, which implements a backoff-retry strategy while opening files to prevent the above-mentioned error. Become a master of finding and using Node modules that have already solved common issues for you.

3. Throttle via chunking data:

Chunking involves grouping computational units into manageable sets, say 10 a piece, and processing these in parallel. Given that we need to process a large number of items, we can implement throttling using a promises library like Bluebird.js. Chunking using the Promises.map() API would look like:

Promise.map(items, function(item) {

  //Do something with item here

}, {concurrency: 10}).then(function() {

    //All items have been processed
    // in sets of 10 at most

});

Note the {concurrency: } parameter to Promise.map(). This ensures that only 10 items will be processed at one time per request. Of course, here we assume that each computation will use a roughly equal amount of memory, which may or may not be a valid assumption for every use case. Chunking helps a microservice always stay within healthy resource consumption limits.

While we can control how many computational unit processed via chunking, web-based microservices typically cannot control the number of incoming requests. Limiting each request only process 10 files at a time is insufficient, since a burst of load may result in hundreds of these requests coming in within a few seconds.

What are your options around these types of scenarios?

4. Use queuing with decoupled post-back:

One option, of course, is to employ an external queue. Each service instance reads from a queue rather than directly receiving requests. While this is possible there are two rather significant drawbacks -- first, setup and maintenance of an external queue is non-trivial and second, queues generally don't guarantee exactly-once delivery where multiple clients are involved.

Another simpler option exists: use an internal concurrency queue. This is normally combined with some sort of decoupled post-back after the microservice completes it's task, like so:


The client immediately receives a '202 Accepted' from the microservice indicating that the task was received, but the actual 'Task Completed' message is decoupled from the original request and involves either a webhook or an update to an external resource.

Here's how this could be implemented using a simple FIFO-based concurrency queue:

First setup the queue:

var cQ = require('concurrent-queue').createInstance({ 'maxConcurrency': 3 });  

Then push a job into the queue:

//process incoming request
//send '202 Accepted' to client 

//push job into queue
var jobId = cQ.push(job)  

The queue generates a unique jobId for each job instance pushed into the queue. When it's safe to process a computational unit, based on the maxConcurrency rule, the queue let's you know by firing a 'ready' event. So we listen for this event like so:

cQ.on('ready', function (job, jobId, status) {  
    //Do some job processing here 

    //when complete, drain the queue of the job 
    cQ.drain(jobId); 

    //send 'Complete' Message or webhook
});

When processing is completed, we drain the queue using .drain(jobId) which releases next job in queue for processing, if one exists. The status field shows current state of the queue: { maxConcurrency: xx, processing: xx, queued: xx }.

This sort of internal queue can give a reasonable guarantee that your microservice does not get overwhelmed by a deluge of requests and fail because it overran it's memory constraints.

I hope this was a helpful overview of some concurrency-based throttling strategies used in Node.js. Feel free to drop me a line if there are other strategies you found useful.


To view or add a comment, sign in

More articles by Ash Isaac

  • DevOps Is For Humans

    Here’s a question I’d like to present for your consideration: Should DevOps adoption influence your decision to work at…

    2 Comments
  • Going Serverless with AWS Lambda? Here's what you should know

    Serverless has gone mainstream now: it's the natural next-step in the evolution of cloud-computing. The value…

  • DevOps In 3 Sentences

    DevOps is a hot topic right now, and amidst the marketing hype and buzzword-frenzy it can be hard to get to the essence…

  • The value of technical leadership

    I've been in technical leadership roles for over a decade and I have to say, in large measure it is quite unglamorous…

    1 Comment
  • Docker Swarm: An overview

    Docker adoption, especially in large-scale companies is on the rise (up 40% apparently, by one estimation). Since v1.

    2 Comments
  • Infrastructure as Code using Terraform

    One of the strategic benefits of Cloud-computing is the concept of programmable infrastructure or "Infrastructure as…

    5 Comments
  • From Monolith to Microservices

    The adoption of microservice-based architectures in enterprise software systems seems to be a growing trend. The…

  • Microservices: The Rationale

    Why Microservices? There is increasing demand that enterprise software systems be Elastic, Resilient and Agile…

  • Software Architecture "Virtues"

    Software architecture is all about appropriate compromises. For small projects, having a highly involved software…

  • The Service Layer Pattern

    One of the key ingredients in managing Enterprise software products is identifying and consistently applying a proven…

Others also viewed

Explore content categories