In modern data APIs, asynchronous background tasks are not just common—they're fundamental to almost every operation. Whether it's database operations, sending emails, or processing webhooks, these behind-the-scenes tasks form the backbone of most services.
Most importantly: The way you manage asynchronous operations in your API handlers can significantly impact your service's overall performance and reliability.
When a request hits our API and we run all of its logic sequentially, the response can end up being unnecessarily delayed.
This increases latency for the end user or, worse, can result in failed responses—such as when one of the asynchronous tasks encounters an error.
Handling asynchronous tasks is like running a marathon for our system:
it requires endurance, coordination, retries, and error handling at the right points.
There are multiple ways to handle asynchronous tasks. In part 1 of this blog post series, I will explore how we can manage asynchronous tasks within a single Cloudflare Worker.
Example: A Marathon Registration API
Imagine this: We are organizing a marathon and need to build an api to register participants.
We set up a simple data API with Hono on Cloudflare Workers, which stores the runners' information in a database. After a successful registration, we send a confirmation email with all the important details for the race.
In this example, we will store registration data in a Neon database, and we'll use Resend to send out emails for us.
There is a repo with the code examples here
Sequential Execution
Let's start by looking at running all tasks sequentially.
app.post('/api/marathon-sequential',async (c) => {
const { firstName, lastName, email, address, distance } = await c.req.json()
await insertData(firstName, lastName, email, address, distance, c.env.DATABASE_URL);
await sendMail(email, c.env.RESEND_API, firstName)
return c.text('Thanks for registering for our Marathon', 200)
})
In the code above, we first insert the runner's data into the database and then send the confirmation email.
All the asynchronous tasks (functions) are called sequentially.
Although this code works, it slows down the return to the user.
If we look into the trace provided in Fiberplane Studio, we see that tasks are running in sequential order, and the total response time depends on the whole sequence.
The trace visualization helps us understand the performance impact of sequential execution on our application:
Fire and Forget
One way to return quickly is by not waiting for other tasks to complete successfully.
Generally, this can be useful if, from a business perspective, it doesn’t matter whether the email is eventually sent or not.
If you don’t need a guarantee that the email will be sent (e.g., for non-critical notifications), you can simply fire the promise and proceed without waiting for it.
While it is essential for a successful registration to store the runner’s details in the database, we can argue that the email we send is not mission-critical, so we don’t need to wait for the promise to return.
Taking this into account, we could modify the code like so:
app.post("/api/marathon-fire-and-forget", async (c) => {
const { firstName, lastName, email, address, distance } = await c.req.json();
await insertData(
firstName,
lastName,
email,
address,
distance,
c.env.DATABASE_URL
).catch(console.error);
sendMail(email, c.env.RESEND_API, firstName).catch(console.error);
return c.text("Thanks for registering for our Marathon", 201);
});
However, Cloudflare Workers are ephemeral, and your Worker might terminate after you return a response. This means dangling promises may not finish successfully, which could result in the email never being sent.
Therefore, when working with Cloudflare Workers, we should ensure they are built in a way that gives promises a chance to complete.
As we can see here in the trace, the response returns after the database insert, but we cannot know whether the email was sent, because the worker shut down before the promise was resolved. The trace helps us identify potential issues with unfinished tasks in our fire-and-forget approach.
On another note: Do you want your support team assisting runners who didn't receive a confirmation email and are now worried they haven't signed up for the race? (Probably not!)
Concurrent Execution: Know Your Business Logic's Dependencies!
If we want to return a response to the user while still performing background tasks in our Worker, we can use executionCtx.waitUntil()
.
This allows us to send a response immediately and handle tasks like email and database updates in the background.
If there are multiple tasks to run in parallel, we can use Promise.all()
to group them.
const tasks = [
sendMail(email, c.env.RESEND_API, firstName),
insertData(firstName, lastName, email, address, distance, c.env.DATABASE_URL),
];
c.executionCtx.waitUntil(Promise.all(tasks));
If we now look at the trace, we see the response returns immediately,
and after a short while, the client instrumentation also shows the other two tasks related to the trace.
This method leads to the parallel execution of both tasks,
and the trace clearly shows how this improves our response time compared to the sequential approach:
However, in our example, we want to ensure that the user’s data is stored in the database before returning a response to the user.
Without this step, we can’t confirm their registration in the system.
So, we actually do have a dependency on the database insert and a required sequential flow.
However, instead of waiting for the email promise before returning, we could use waitUntil()
to keep the worker open and return as follows:
app.post("/api/marathon-waituntil", async (c) => {
const { firstName, lastName, email, address, distance } = await c.req.json();
await insertData(
firstName,
lastName,
email,
address,
distance,
c.env.DATABASE_URL
).catch(console.error);
c.executionCtx.waitUntil(sendMail(email, c.env.RESEND_API, firstName));
return c.text("Thanks for registering for our Marathon", 201);
});
Similar to the fire and forget trace, we see that the response is sent after the database insert, but the email is sent after the response is returned and starts after the database insert finishes.
The trace confirms our intended execution order, all while showing improved response times.
Error handling and retries
In our scenario, the approach using waitUntil()
for sending the email is the most suitable. However, there are still a few more things to consider.
What happens if sending the email fails? How can we ensure that tasks are retried if they encounter an error?
While we could implement custom logic for retries and error handling, this isn’t always the most effective solution.
This is where we could break down tasks across separate workers to streamline handling and retries.
In the next part of this blog post series, we’ll explore how to manage asynchronous tasks across multiple workers and ensure tasks are retried if they fail.
Top comments (0)