14  Async

R is single-threaded. When a route handler is busy – running a query, fitting a model, generating a report – the entire server blocks. No other request can be processed until that handler finishes.

For a quick JSON lookup, this is fine. For a 10-second database query or a heavy computation, it means every other user is stuck waiting.

Asynchronous programming solves this. Instead of blocking the thread, the expensive work runs in a separate process. The handler returns a promise – a placeholder for a value that doesn’t exist yet – and Ambiorix moves on to serve the next request. When the work finishes, the promise resolves and the response is sent.

Ambiorix supports async out of the box. If your route handler returns a promise, Future, or mirai object, Ambiorix detects it automatically and defers the response.

14.1 The Problem

Let’s make the problem concrete. Run this app:

library(ambiorix)

app <- Ambiorix$new()

app$get("/slow", function(req, res) {
  Sys.sleep(10)
  res$text("Done!")
})

app$get("/fast", function(req, res) {
  res$text("Hello!")
})

app$start(port = 3000L)

Open two browser tabs. Visit /slow in the first, then immediately visit /fast in the second.

You’ll wait 10 seconds for both responses. The /fast handler can’t run until /slow finishes because Sys.sleep(10) blocks R’s only thread.

14.2 Using {future}

The {future} package lets you evaluate an expression in a background R process. Wrap the blocking work inside future() and return the result from the handler. Ambiorix sees the Future object and handles it as a promise.

library(future)
library(ambiorix)

plan(multisession)

app <- Ambiorix$new()

app$get("/slow", function(req, res) {
  future({
    Sys.sleep(10)
    res$text("Done!")
  })
})

app$get("/fast", function(req, res) {
  res$text("Hello!")
})

app$start(port = 3000L)

Now try the same two-tab test. Visit /slow, then immediately visit /fast. This time, /fast responds instantly. The main thread is free because the 10-second sleep is running in a separate R process.

The key changes:

  1. library(future) and plan(multisession) – loads the package and tells it to use background R processes.
  2. future({ ... }) – wraps the blocking work. The expression inside the braces runs in a worker process.
  3. The handler returns the Future object. Ambiorix detects it and waits for the promise to resolve before sending the response.

The last expression inside the future() block must be a valid response – a call to a res method like res$text(), res$json(), res$send(), etc. This is the same rule as for synchronous handlers.

14.2.1 Choosing a Plan

plan() controls where futures run. The most common strategies:

Plan Description
multisession Separate R sessions on the same machine. Works on all platforms.
multicore Forked R processes. Faster startup, but only on Linux/macOS and not in RStudio.
cluster R sessions on remote machines. For distributed workloads.

For most Ambiorix applications, plan(multisession) is the right default.

Learn more about execution plans in the {future} documentation.

14.3 Using {mirai}

{mirai} is an alternative async framework for R. The name means “future” in Japanese.

Where {future} uses plans, {mirai} uses daemons – persistent background processes that receive and execute tasks.

library(mirai)
library(ambiorix)

daemons(4)

app <- Ambiorix$new()

app$get("/slow", function(req, res) {
  mirai({
    Sys.sleep(10)
    res$text("Done!")
  }, res = res)
})

app$get("/fast", function(req, res) {
  res$text("Hello!")
})

app$start(port = 3000L)

Notes:

  • daemons(4) starts 4 persistent worker processes. Tasks are dispatched to available daemons automatically.
  • mirai({ ... }, res = res) – the expression in braces runs in a daemon. Objects needed inside the expression must be passed explicitly. Here we pass res so the daemon can call res$text().
  • The handler returns the mirai object. Ambiorix detects it and resolves it as a promise, just like with {future}.

The last expression inside the mirai() block must be a valid response, same as with future().

14.3.1 {mirai} vs {future}

Both work. The choice depends on your needs:

{future} {mirai}
Worker model Created per plan, reusable Persistent daemons, explicitly started
Scheduling Round-robin or sequential Optimal FIFO via dispatcher
Distributed Yes, via plan(cluster) Yes, via remote daemons with SSH tunnelling
Promise type Polling-based Event-driven (zero-latency)

{mirai} promises are event-driven: they resolve the instant the daemon finishes, with no polling loop. This gives lower latency under high concurrency. For most Ambiorix applications, either package works well.

14.4 Error Handling

When an async handler fails, Ambiorix catches the error and routes it to the error handler – the same one you’d set for synchronous routes.

plan(multisession)

app$get("/fail", function(req, res) {
  future({
    stop("something went wrong")
  })
})

app$error <- function(req, res, error) {
  message(conditionMessage(error))
  res$status <- 500L
  res$text("Internal Server Error")
}

If the promise rejects (the expression inside future() or mirai() throws an error), Ambiorix logs the error message to the console and calls the route’s error handler. If no route-specific handler exists, the global error handler is used.

This is the same error handling flow described in the Errors chapter. The only difference is that the error happens asynchronously.

14.5 What Can Be Async

Only route handlers can be async. Middleware runs synchronously, before the handler.

This means you cannot return a promise from middleware. If you need to do async work before the handler runs (eg. an async authentication check), do it inside the route handler itself.

14.6 A Practical Example

Here’s a more realistic example: an endpoint that runs a slow database-style query asynchronously while keeping the rest of the application responsive.

library(future)
library(ambiorix)

plan(multisession)

#' Simulate a slow query
#'
#' @param id User ID.
#' @return A list with user data.
slow_query <- function(id) {
  Sys.sleep(3)
  list(
    id = id,
    name = paste("User", id),
    email = paste0("user", id, "@example.com")
  )
}

app <- Ambiorix$new()

app$get("/", function(req, res) {
  res$text("API is running.")
})

app$get("/users/:id", function(req, res) {
  id <- req$params$id

  future({
    user <- slow_query(id)
    res$json(user)
  })
})

app$start(port = 3000L)

Visit /users/42 in one tab and / in another. The home page responds immediately, even though the user lookup takes 3 seconds.

14.7 Dependencies

Ambiorix’s async support depends on the {promises} package, which is listed as an optional dependency. If your handler returns a promise but {promises} is not installed, the request will fail.

Make sure you install the packages you need:

  • For {future}: install.packages(c("future", "promises"))
  • For {mirai}: install.packages(c("mirai", "promises"))

{promises} is the bridge. Both {future} and {mirai} implement the promise protocol that Ambiorix uses internally to defer and resolve responses.