Menu
Defer is reaching the end of its service life on May 1, 2024. Please reach out to support@defer.run for further information.
Engineering
February 16th, 2024

Delaying work in Node.js: Promises vs Queues

Charly PolyCharly PolyCEO

6 years ago, Jake Archibald gave a legendary talk on JavaScript's event loop mechanism.

The Event Loop differentiates JavaScript from other languages, such as PHP or Ruby, by giving it asynchronous capabilities through its Promise or setInterval() APIs (to name a few).


The asynchronous nature of JavaScript has been especially helpful to the success of Node.js as a performant back-end language.

However, sometimes, being performant also means doing less. That's why offloading long-running or CPU-consuming work outside the user journey is common among API developers.

How should we delay the code out of the user journey? Using Promises, Child executions, Queues?


This article elucidates the distinctions between these approaches, complemented by code examples to showcase their practical applications.



Promises: Managing Asynchronous Operations Gracefully

A Promise in JavaScript represents an operation that hasn't been completed yet but is expected in the future. This feature allows methods to return values asynchronously and has been a long-awaited solution to Node.js's callback hell.

Promises are pretty handy to delay some units of work and achieve some simple parallelism:


Example:

// Example of Node.js Promise Execution Orderfunction resolveAfterDelay(delay, value) {  return new Promise((resolve) => {    setTimeout(() => {      console.log(value) // Log when the promise is resolved      resolve(value)    }, delay)  })}
console.log("Execution started")
// Setting up three promises with different delaysconst promise1 = resolveAfterDelay(  3000,  "First promise resolved after 3 seconds")const promise2 = resolveAfterDelay(  1000,  "Second promise resolved after 1 second")const promise3 = resolveAfterDelay(  2000,  "Third promise resolved after 2 seconds")
// Executing promisesPromise.all([promise1, promise2, promise3]).then(() => {  console.log("All promises have been resolved")})

The above examples show how Promise can be leveraged to create delays and control the order of asynchronous executions.


Keep in mind that Promises are bound to the microtasks queue of the main Node.js process, meaning that:

  • Pending Promises are lost upon the parent program's termination (ex: the API).
  • By sharing the same runtime, Promises impact the API's RPM/throughput.

Let's look at another way to offload resource-consuming tasks with Child Processes.



Child Processes: Executing External Programs

Child processes allow Node.js applications to run external processes in the system environment (with the child_process module). This can be particularly useful for executing system commands, running external scripts or applications, dealing with heavy computational tasks separately, or parallel processing.

Unlike Promises, a Child process won't impact the API's performance but will share the same system's resources.


Example:

const { spawn } = require("child_process")
const arguments = JSON.stringify({  /* ... */})const child = spawn("node", ["./some/other/script.js", arguments])
child.stdout.on("data", (data) => {  console.log(`stdout: ${data}`)})
child.stderr.on("data", (data) => {  console.error(`stderr: ${data}`)})
child.on("close", (code) => {  console.log(`child process exited with code ${code}`)})

This code snippet demonstrates the spawning of a child process to execute another resource-consuming script. The stdout and stderr streams are listened to for data to handle the command's output and potential errors.

While solving the performance impact faced with Promise, Child Processes are still bound to their parent process, leaving the reliability issue unsolved.


Now, let's look at a third solution that brings a scalable and reliable approach to asynchronous work: Background Jobs.



Background Jobs: Offloading Tasks to Run in Background

Background jobs in Node.js typically refer to tasks dispatched to run behind the scenes, often managed by job queues like Bull or a platform like Defer. These tasks involve sending emails, performing database maintenance, or other time-consuming operations that you wouldn't want to block the main application flow.


Example with Defer:

import { defer } from "@defer/client"import Stripe from "stripe"
async function handleStripeEvent(eventID) {  const stripe = new Stripe(process.env.STRIPE_PRIVATE_API_KEY)  const event = await stripe.events.retrieve(eventID)  switch (event.type) {    case "customer.created":      break    case "customer.deleted":      break    default:      console.log(`skip ${eventID} event`)  }}
export default defer(handleStripeEvent, {  // retry the Stripe event processing  //   with an exponential back-off retry strategy  retry: 7,})

Here, the Defer Background function contains some work not required to run within the API's journey. The processing of webhooks is a common use case that often requires some background processing to avoid losing events or processing them in the wrong order.

Find a complete Stripe use case walkthrough in our guide.



Conclusion

  1. Scalability and Overhead: Promises are excellent for handling asynchronous results within the application, imposing minimal overhead. Though powerful for leveraging system-level tasks, child processes introduce more complexity and resource overhead due to the creation of separate processes. With the help of queues, background jobs are ideal for offloading heavy or time-consuming tasks but require additional infrastructure and management if set up manually.

  1. Complexity and Control: Promises provide a comparatively straightforward mechanism for dealing with asynchronous operations, offering fine-grained control with .then(), .catch(), and .finally() handlers. Child processes offer a high level of control over external operations but require a solid understanding of process management, inter-process communication (IPC), and error handling. Background jobs abstract much of the complexity using job queues, though they necessitate a clear strategy for concurrency, retry logic, and failure handling.

  1. Use Cases: Promises are ubiquitous in JavaScript programming and suitable for any scenario where asynchronous operations like API calls or file operations need to be managed. Child processes shine in scenarios requiring external command execution, heavy computation in isolation, or parallel processing. Background jobs are particularly relevant for operations that can be deferred or run periodically, like batch processing, scheduled tasks, or email notifications.
Join the community and learn how to get started, and provide feedback.
Stay tuned about our latest product and company updates.
Start creating background jobs in minutes.
Copyright ©2024 Defer Inc.