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.
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 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:
Let's look at another way to offload resource-consuming tasks with Child Processes.
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.
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 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.
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.
This first Build week has been the opportunity to share all the issues that Defer is solving for Serverless and LLM applications as well as when building complex no-code user experiences.