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:
library(future)andplan(multisession)– loads the package and tells it to use background R processes.future({ ... })– wraps the blocking work. The expression inside the braces runs in a worker process.- The handler returns the
Futureobject. 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 passresso the daemon can callres$text().- The handler returns the
miraiobject. 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.