Skip to contents

Shiny’s ExtendedTask is the standard way to run slow work off the session thread. shinyasync does the same job through a smaller API: each async reactive is a cancellable task modelled on ExtendedTask, on the same mirai daemons. This vignette writes the same app both ways, one piece at a time, so you can see what the layer removes and what it costs.

The running example is the surf forecast dashboard from shinyasync-example. fetch_marine() and fetch_wind() call Open-Meteo for a spot and return a data frame of hourly conditions; score_hours() combines the two into a surf score. They are plain functions defined in the app’s R/ directory.

The code here shortens their signatures so the two shapes stay side by side. In the app fetch_marine(lat, lon, days, delay) and fetch_wind() take the coordinates from get_spot(input$spot) and the day count and simulated latency from two more inputs, score_hours(marine, wind, spot) takes the spot as well, and the simulation is simulate_scores(scored, spot, n, delay). simulate_band(), write_cache(), save_favourite() and fetch_and_score() stand in for app helpers of the same shape; the app ranks all spots in one rank_spots(spots, days) task rather than one per spot.

The pool

Both run on mirai daemons. With ExtendedTask you start and stop them yourself; with shinyasync the first task starts a pool and the last session to use it shuts it down. pool_async() is there to size it.

# ExtendedTask
daemons(4)
onStop(function() daemons(0))
# shinyasync
pool_async(workers = 4)

The arguments of pool_async() pass through to daemons(), so a remote or TLS-backed pool is set up the same way. If you already manage daemons, call neither.

A slow fetch from an input

The forecast depends on the selected spot. In plain Shiny it is reactive(fetch_marine(input$spot)).

ExtendedTask takes a function, not an expression. Reactive reads inside it are masked, so the values go in as arguments, an observer calls invoke() when the input changes, and outputs read result(). mirai ships nothing it is not given, so the helper is passed in by name too.

# ExtendedTask
marine_task <- ExtendedTask$new(function(spot) {
  mirai(fetch_marine(spot), spot = spot, fetch_marine = fetch_marine)
})
observe(marine_task$invoke(input$spot))

output$marine <- renderPlot(plot_swell(marine_task$result()))
# shinyasync
marine <- reactiveAsync(fetch_marine(input$spot))

output$marine <- renderPlot(plot_swell(marine()))

reactiveAsync() reads input$spot on the session thread, where it registers the dependency, and sends the value to the worker with the expression. fetch_marine() and every function it calls are found by analysing the expression and shipped with it. The result is a reactive, so nothing else in the app knows a worker was involved.

If fetch_marine() were left out of the mirai() call above, the task would fail on the worker with could not find function "fetch_marine". The usual fix is everywhere(source("R/api.R")) after daemons(); shinyasync’s equivalent is pool_async(everywhere = ), which is optional because tasks ship what they need.

Status and the previous value

Both report "initial", "running", "success" and "error", and both halt an output that reads a task that has not run yet, so status code moves over unchanged. shinyasync adds one status, "idle", after a cancel or a worker-side req() halt with no value. The difference is in what happens to the previous value.

# ExtendedTask
output$status <- renderText(marine_task$status())    # "initial" before the first invoke
# shinyasync
output$status <- renderText(status_async(marine))    # "initial" before the first run

invoke() clears the stored value, and while the task runs result() halts with cancelOutput = "progress": the output keeps whatever it last rendered, greyed out, but a reactive that reads result() gets nothing. An async reactive keeps its previous value while a new one computes, so the old forecast stays on screen and can still be read by other reactives. has_value_async() tells the first run, which wants a spinner, from a refresh, which wants a note:

# shinyasync
output$note <- renderUI({
  running <- status_async(marine) == "running"
  if (running && !has_value_async(marine)) icon("spinner", class = "fa-spin")
  else if (running) span(class = "text-muted", "Showing the previous forecast")
})

A button, and cancelling

The Monte Carlo band around the score should only run on a click, and the user should be able to stop it.

ExtendedTask has a good story for the button: bslib::input_task_button() and bind_task_button() disable the button while the task runs, with no code in the server. shinyasync registers methods for the same generic, so that line carries over. ExtendedTask has no story for cancelling. The task does not expose its mirai, so to stop it you keep a reference yourself, and stop_mirai() then resolves the task with an error:

# ExtendedTask
current <- NULL
sim_task <- ExtendedTask$new(function(spot, marine, wind) {
  current <<- mirai(simulate_band(spot, marine, wind), spot = spot, marine = marine,
                    wind = wind, simulate_band = simulate_band)
  current
}) |> bslib::bind_task_button("go")

observe(sim_task$invoke(input$spot, marine_task$result(), wind_task$result())) |>
  bindEvent(input$go)
observe(stop_mirai(current)) |> bindEvent(input$cancel)

output$band <- renderPlot(plot_band(sim_task$result()))

After the cancel click the status is "error", result() throws 20 | Operation canceled into the output, and Shiny warns that the task failed. Showing the previous band instead means catching that error and storing the last good value in a reactiveVal of your own.

# shinyasync
sim <- reactiveAsync(simulate_band(input$spot, marine(), wind())) |>
  bindEvent(input$go) |>
  bslib::bind_task_button("go")
observe(cancel_async(sim)) |> bindEvent(input$cancel)

output$band <- renderPlot(plot_band(sim()))

bindEvent() and bind_task_button() work on an async reactive as on an ExtendedTask. cancel_async() stops the worker, sets the status to "idle" and keeps the last band, so has_value_async() stays TRUE, the output does not change until the next click, and the button returns to ready. Without bslib, a plain button that disables itself reads the status:

# shinyasync
observe(updateActionButton(session, "go", disabled = status_async(sim) == "running"))

Inputs that change while a task runs

Drag the spot selector through three spots while a fetch is running. ExtendedTask queues: the three invocations run one after another on a single worker, and the output settles on the third after all have finished. A plain reactive() would only ever compute the latest.

# shinyasync
marine <- reactiveAsync(fetch_marine(input$spot))                        # cancels, runs latest
saved <- reactiveAsync(save_favourite(input$favourite), mode = "queue")  # runs every one

output$saved <- renderText(saved())

reactiveAsync() cancels the running fetch and starts the latest, as a reactive would. mode = "queue" gives ExtendedTask’s behaviour for work that must not be skipped, such as saving each spot the user marks as a favourite. Like any reactiveAsync(), a queued one starts only once something reads it, here the confirmation text. observeAsync() has no queue mode: a dependency change cancels its running task.

A task that depends on another

The score needs both forecasts. In plain Shiny it is reactive(score_hours(marine(), wind())).

With ExtendedTask the score is a third task, invoked from an observer that reads the first two. result() halts while a fetch is running, so the observer never pairs a new marine forecast with an old wind, which is right. But the score task is not told: while both fetches run for the new spot it still reports "success" and result() still returns the old spot’s score, so a status badge on the score says it is done when it is about to change, and nothing downstream can tell the difference.

# ExtendedTask
score_task <- ExtendedTask$new(function(marine, wind) {
  mirai(score_hours(marine, wind), marine = marine, wind = wind, score_hours = score_hours)
})
observe(score_task$invoke(marine_task$result(), wind_task$result()))

output$score <- renderText(score_task$result())
# shinyasync
scored <- reactiveAsync(score_hours(marine(), wind()))

output$score <- renderText(scored())

An async reactive that reads another one waits for it: while marine is catching up with a new spot, scored keeps its previous value, reports "running" for as long as either fetch does, and runs once on the settled pair. The expression is the one you would have written for reactive().

Side effects and fan-out

Writing each scored forecast to a cache is an observer in plain Shiny. With ExtendedTask it is a task nobody reads, plus the observer that invokes it.

# ExtendedTask
cache_task <- ExtendedTask$new(function(spot, scored) {
  mirai(write_cache(spot, scored), spot = spot, scored = scored, write_cache = write_cache)
})
observe(cache_task$invoke(input$spot, score_task$result()))
# shinyasync
observeAsync(write_cache(input$spot, scored()))

Ranking all ten spots on a click is one task per spot. With ExtendedTask that is one task object per spot, each with its own invoke. shinyasync’s imperative pair is reactiveValAsync(), a reactive value whose setter takes a mirai, and mirai_async(), which starts a mirai with the same capture as reactiveAsync():

# shinyasync
ranked <- lapply(seq_along(spots), function(i) reactiveValAsync())

observe({
  for (i in seq_along(spots)) {
    ranked[[i]](mirai_async(fetch_and_score(spots[[i]], days = input$days)))
  }
}) |> bindEvent(input$rank)

output$ranking <- renderTable(do.call(rbind, lapply(ranked, function(r) r())))

Each value is NULL until its task has returned and rbind() skips the NULLs, so the table shows the spots whose tasks are done and gains a row as each of the others arrives.

Modules and tests

Inside moduleServer() nothing changes for either: ExtendedTask is created with the module’s input, and reactiveAsync() captures the namespaced input and the module’s local functions like any other reactive. A module can return the async reactive to its caller, as it would return a reactive().

Testing is the same for both. shiny::testServer() has to let the worker finish and let Shiny see the result, which takes a few lines of later::run_now() and session$flushReact(); vignette("testing") has them.

What the layer costs

shinyasync finds the reactive reads in the expression by looking at it, which ExtendedTask never has to do. The limits follow from that, and the README section “How dependency capture works” lists them. The ones you are most likely to meet:

  • Reads must appear literally in the expression. input[[paste0("x", i)]] or a read inside a helper function cannot be resolved; read it in a reactive() first, or pass the value with globals = list(...).
  • Every read is a dependency, even in a branch that is not taken, unless it is wrapped in isolate().
  • The task must be a mirai. ExtendedTask accepts any promise, so a future, callr or httr2::req_perform_promise() task needs ExtendedTask, or reactiveValAsync() fed by hand.
  • The OpenTelemetry spans Shiny records for an ExtendedTask have no counterpart here yet.

Summary

ExtendedTask shinyasync
Written as task object, invoking observer, result() reads reactiveAsync(expr)
Reactive reads passed as arguments captured from the expression
Helpers on the worker passed by name, or everywhere() shipped automatically
Input changes while running queued cancelled and re-run (mode = "queue" to queue)
Previous value while running cleared; output greyed out kept; has_value_async() tells first run from refresh
Cancel keep the mirai yourself; task ends in "error" cancel_async(); status "idle", value kept
Task button bind_task_button() bind_task_button()
Depends on another task another task and observer read it in the expression
Side effect task nobody reads, plus observer observeAsync()
Task source any promise mirai
Status values initial, running, success, error the same, plus idle after a cancel or a halt with no value