Production application design

Lesson 11 — R Shiny

Lesson 11 of 11 Advanced ~90 min

Learning objectives

  • Structure a Shiny application as a package
  • Design the data layer so the app scales past in-memory objects
  • Profile and fix the performance problems that actually occur
  • Handle errors and logging so production failures are diagnosable
  • Manage configuration across environments
  • Meet the expectations of a validated application

The app as a package

For anything that will be maintained by more than one person, structure the app as an R package. golem and rhino are the two established frameworks.

golem::create_golem("abc101review")
abc101review/
├── DESCRIPTION            # dependencies declared and versioned
├── NAMESPACE
├── R/
│   ├── app_ui.R
│   ├── app_server.R
│   ├── app_config.R
│   ├── run_app.R
│   ├── mod_filters.R
│   ├── mod_table.R
│   └── fct_derive.R       # business logic — plain, testable functions
├── inst/
│   ├── app/www/           # static assets
│   └── golem-config.yml
├── tests/testthat/
├── man/
└── dev/                   # scripts used during development, not shipped

What this buys you:

Property Loose scripts Package
Dependencies Implicit DESCRIPTION, checked
Documentation Comments ?function
Tests Ad hoc R CMD check runs them
Versioning Folder names DESCRIPTION + Git tag
Install Copy files install.packages()
Namespacing Global Controlled exports

Naming conventions in golem: mod_*.R for modules, fct_*.R for business logic, utils_*.R for helpers. Arbitrary but useful — you can tell what a file contains from its name.

The rule that matters most: fct_*.R files must not mention Shiny. All the derivation, formatting and validation logic lives there, is tested with plain testthat, and could be reused by a batch script. Modules become thin.

The data layer

The single biggest architectural decision.

In memory

# R/app_server.R
app_server <- function(input, output, session) {
  adsl <- readRDS(app_sys("extdata/adsl.rds"))     # loaded once at startup
  ...
}

Fine up to a few hundred MB. Every process holds a full copy, so memory is data_size × n_processes.

Database

library(DBI)
library(dbplyr)

# One pool for the whole app, not one connection per session
pool <- pool::dbPool(
  drv      = RPostgres::Postgres(),
  host     = Sys.getenv("DB_HOST"),
  dbname   = "study_db",
  user     = Sys.getenv("DB_USER"),
  password = Sys.getenv("DB_PASSWORD"),
  minSize  = 1,
  maxSize  = 10
)

onStop(function() pool::poolClose(pool))

server <- function(input, output, session) {

  filtered <- reactive({
    tbl(pool, "adsl") |>
      filter(TRT01P == !!input$arm,
             AGE >= !!input$age[1],
             AGE <= !!input$age[2]) |>
      collect()                          # the query runs HERE
  })
}

dbplyr translates the dplyr verbs to SQL and pushes the work to the database. collect() is where it executes and the data comes back — put it as late as possible.

ImportantUse pool, never a raw connection
# WRONG — one connection per session, exhausts the database
server <- function(input, output, session) {
  con <- dbConnect(...)
  onSessionEnded(function() dbDisconnect(con))
}

pool manages a shared set of connections, hands one out for the duration of a query, and returns it. It also reconnects transparently after a network blip, which a raw connection does not.

Always parameterise queries:

# SQL INJECTION
dbGetQuery(pool, paste0("SELECT * FROM adsl WHERE USUBJID = '", input$id, "'"))

# Safe
dbGetQuery(pool, "SELECT * FROM adsl WHERE USUBJID = $1", params = list(input$id))

# Or via glue_sql
dbGetQuery(pool, glue::glue_sql(
  "SELECT * FROM adsl WHERE USUBJID = {input$id}", .con = pool))

Columnar files

For read-heavy analytical data that does not change during a session, Parquet plus arrow is often better than a database:

library(arrow)

ds <- open_dataset("data/adlb", format = "parquet")   # partitioned directory

filtered <- reactive({
  ds |>
    filter(PARAMCD == input$param, AVISITN == input$visit) |>
    select(USUBJID, AVAL, CHG, TRT01P) |>
    collect()
})

arrow reads only the columns and row groups it needs, so a 5 GB dataset can be queried in a Shiny app without loading it. Partition by the column you filter on most:

data/adlb/
├── PARAMCD=ALT/part-0.parquet
├── PARAMCD=AST/part-0.parquet
└── PARAMCD=BILI/part-0.parquet

Performance

Profile first

library(profvis)

profvis({
  runApp("myapp")
  # interact with it
})

Or in production, shiny::runApp() with:

options(shiny.reactlog = TRUE)

Do not optimise from intuition. The bottleneck is almost never where you expect — it is usually a query being run three times, or a table being re-rendered on an unrelated input change.

The fixes, in order of usual payoff

1. Compute once, consume many times

data <- reactive(expensive_query(input$x))    # one call
output$a <- renderPlot(plot(data()))
output$b <- renderDT(data())
output$c <- renderText(nrow(data()))

2. Cache

output$plot <- renderPlot(make_plot(input$param, input$arm)) |>
  bindCache(input$param, input$arm)

shinyOptions(cache = cachem::cache_disk("./cache", max_size = 1e9))

3. Defer expensive work to a button

results <- reactive(run_model(data(), input$params)) |> bindEvent(input$run)

4. Reduce what crosses to the browser

# Server-side DataTables: only the visible page is sent
output$table <- renderDT(big_data, server = TRUE)

# Aggregate instead of plotting 500,000 points
ggplot(big, aes(x, y)) + geom_hex(bins = 60)

5. Move blocking work off the main process

An R process serves one thing at a time. A 30-second computation freezes every user of that process.

library(promises)
library(future)
plan(multisession, workers = 4)

output$result <- renderPlot({
  future_promise({
    slow_computation(isolate(input$n))     # runs in a worker process
  }) %...>% {
    plot(.)
  }
})

Constraints on future_promise(): the code runs in a different process, so it cannot touch reactive values (isolate and pass them in), and objects it references are serialised and copied. It is worth the awkwardness only for genuinely long computations.

Startup time

# SLOW: 20 seconds of startup per process
adsl <- read_sas("adsl.sas7bdat")
adae <- read_sas("adae.sas7bdat")
adlb <- read_sas("adlb.sas7bdat")

# FAST: pre-convert once, offline
# In a data-prep script:
#   arrow::write_parquet(read_sas("adsl.sas7bdat"), "data/adsl.parquet")
adsl <- arrow::read_parquet("data/adsl.parquet")

Startup time is paid on every process start, and with min_processes = 0 that is every cold user. Pre-convert source formats offline.

Error handling and logging

library(logger)

log_appender(appender_tee(file.path("logs", "app.log")))
log_threshold(INFO)
log_layout(layout_glue_generator(
  "[{format(time, '%Y-%m-%d %H:%M:%S')}] {level} {msg}"
))

server <- function(input, output, session) {

  log_info("Session {session$token} started (user: {session$user %||% 'anon'})")

  session$onSessionEnded(function() {
    log_info("Session {session$token} ended")
  })

  # Wrap risky operations
  safe_query <- function(expr, what) {
    tryCatch(
      expr,
      error = function(e) {
        log_error("{what} failed for session {session$token}: {conditionMessage(e)}")
        showNotification(
          paste0("Could not load ", what, ". The team has been notified."),
          type = "error", duration = NULL
        )
        NULL
      }
    )
  }

  data <- reactive({
    safe_query(load_study_data(input$study), "study data")
  })

  output$plot <- renderPlot({
    validate(need(!is.null(data()), "Data could not be loaded."))
    make_plot(data())
  })
}

In production, sanitise errors so internals do not reach the user:

options(shiny.sanitize.errors = TRUE)

The user sees “An error has occurred. Check your logs or contact the app author for clarification.” — and your log has the real message. Turn it off temporarily when debugging a deployed app.

Log at boundaries: session start/end, data loads, exports, errors, and any action with consequences. Do not log inside reactives that fire on every keystroke.

Configuration

# inst/golem-config.yml
default:
  data_path: "data/"
  cache_size: 100000000
  log_level: "INFO"
  max_upload_mb: 50

development:
  log_level: "DEBUG"
  data_path: "data-test/"

production:
  data_path: "/mnt/studies/abc101/"
  cache_size: 1000000000
  log_level: "WARN"
  max_upload_mb: 200
config <- config::get(file = app_sys("golem-config.yml"))
options(shiny.maxRequestSize = config$max_upload_mb * 1024^2)

Set R_CONFIG_ACTIVE=production in the deployment environment. Secrets stay in environment variables, never in the YAML.

State and sessions

Each session has its own environment. Shared state needs care:

# Global — shared by ALL sessions in this process
shared_cache <- new.env()

server <- function(input, output, session) {
  # Session-scoped
  user_state <- reactiveValues(selection = NULL)

  # Cross-session data must be protected against concurrent access
  observeEvent(input$save, {
    # Two sessions writing simultaneously will corrupt this
    shared_cache$last_export <- Sys.time()
  })
}

If sessions must share mutable state, put it in a database with proper transactions rather than an R environment. R has no locking primitives you can rely on here.

Clean up on session end:

session$onSessionEnded(function() {
  unlink(session_temp_dir, recursive = TRUE)
  log_info("Cleaned up session {session$token}")
})

Temporary files that are never cleaned up will eventually fill the disk, and disk-full is a particularly unpleasant production failure.

Validated applications

If the app produces or supports regulated output, it needs more than good engineering.

Requirement How
Requirements specification Written before the code; each requirement has an ID
Design specification Architecture, modules, data flow
Version control Git, tagged releases, protected main
Code review Pull requests with recorded approval
Unit and integration tests testthat + testServer, traced to requirements
User acceptance testing Scripted, executed, signed
Environment reproducibility renv.lock + container image, both archived
Change control Every change linked to a change request
Audit trail Who used it, what they did, when
Installation qualification Documented deployment and verification

Practical implementation:

# R/app_config.R
APP_VERSION <- "1.2.0"
APP_BUILD   <- Sys.getenv("GIT_SHA", "unknown")

version_footer <- function() {
  tags$div(
    class = "text-muted small",
    sprintf("Version %s (build %s) · R %s",
            APP_VERSION, substr(APP_BUILD, 1, 7), getRversion())
  )
}

Display the version in the UI and stamp it on every export. When someone queries a number, the first question is “which version produced it?” and the answer must be on the artefact.

Traceability from requirement to test:

test_that("REQ-042: exports are restricted to the user's assigned sites", {
  ...
})

Putting the requirement ID in the test name makes the traceability matrix a grep rather than a spreadsheet maintained by hand.

An architecture checklist

Before an app goes to production:

Common mistakes

Mistake Consequence Fix
Logic inside render functions Untestable, unreusable fct_*.R, Shiny-free
Raw DB connection per session Connection exhaustion pool::dbPool()
String-concatenated SQL Injection Parameterised queries
Optimising by intuition Wasted effort profvis first
Blocking computation on the main process All users freeze future_promise() or a button
Reading SAS files at startup Slow cold starts Pre-convert to Parquet
Shared mutable state in an environment Race conditions Database with transactions
No version in the UI Cannot answer “which build?” Display and stamp it
Temp files never cleaned Disk fills onSessionEnded()

Exercise 11.1 — Review an architecture

An app has: a 2 GB dataset loaded at startup, 40 concurrent users, a 45-second model fit triggered by a dropdown, and all logic inside render functions. Identify the problems and propose a design.

Show solution

Problems

  1. 2 GB × N processes. Connect runs several processes per app; ten processes means 20 GB of RAM for the same data. It also makes startup slow, which is paid on every cold start.
  2. 45 seconds of blocking work on a dropdown change. The dropdown fires on every selection, and each fit freezes the entire R process — so all users sharing that process are frozen too. With 40 users this is unusable.
  3. Logic in render functions. Nothing is testable, and the same computation is almost certainly duplicated across outputs.
  4. 40 concurrent users against an unspecified process configuration.

Proposed design

Data layer — stop loading 2 GB into every process.

# Offline, once
arrow::write_dataset(big_data, "data/adlb", format = "parquet",
                     partitioning = "PARAMCD")

# In the app
ds <- arrow::open_dataset("data/adlb")     # metadata only, ~MB

filtered <- reactive({
  ds |>
    filter(PARAMCD == !!input$param, AVISITN == !!input$visit) |>
    select(USUBJID, AVAL, CHG, TRT01P) |>
    collect()                               # only what is needed
})

Memory per process drops from 2 GB to tens of MB, and startup becomes instant.

Long computation — trigger explicitly and move it off the main process.

library(promises); library(future)
plan(multisession, workers = 4)

model_results <- reactive({
  d      <- filtered()
  params <- isolate(list(method = input$method, adj = input$adjust))

  future_promise({
    fit_model(d, params)          # in a worker; cannot touch reactives
  }) %...>% identity()
}) |>
  bindEvent(input$run) |>         # a Run button, not the dropdown
  bindCache(input$param, input$visit, input$method, input$adjust)

output$model_plot <- renderPlot({
  validate(need(!is.null(model_results()), "Click Run to fit the model."))
  plot_model(model_results())
})

Three changes, each necessary: the button stops accidental fits, the cache means a repeated selection is instant, and the future stops one user’s fit blocking the others.

Logic extraction

# R/fct_model.R — no Shiny anywhere in this file
fit_model     <- function(data, params) { ... }
plot_model    <- function(fit) { ... }
summarise_fit <- function(fit) { ... }
# tests/testthat/test-fct_model.R
test_that("fit_model returns coefficients for a known dataset", { ... })
test_that("fit_model errors clearly when a group has too few observations", { ... })

Capacity

With arrow the memory per process is small, so run more processes:

min_processes: 2
max_processes: 8
max_conns_per_process: 6      # 8 × 6 = 48 concurrent connections
idle_timeout: 300

Plus plan(multisession, workers = 4) for the model fits — sized against available cores, not against user count.

Verification

Load test before believing any of this:

shinyloadtest::record_session("https://connect/abc101", output = "recording.log")
# shinycannon recording.log https://connect/abc101 --workers 40 --output-dir run1
shinyloadtest::load_runs("40 users" = "run1") |> shinyloadtest_report()
The report shows where time goes at concurrency — which is frequently not where single-user profiling suggested.

Exercise 11.2 — Production readiness review

You have inherited a Shiny app used to review safety data for an ongoing study. It is a single 1,400-line app.R, deployed by copying files to a server, with no tests. Write the plan to get it to production standard.

Show solution

Sequenced so that each step is independently valuable and nothing is a big-bang rewrite.

Phase 0 — Establish a baseline (week 1)

  1. Put it in Git. Tag the current state v0.1.0-inherited — this is what produced the outputs currently in use, and you must be able to return to it.
  2. renv::init(); renv::snapshot(). Record what actually works today.
  3. Write down what the app does, screen by screen, as a draft requirements list. You cannot test behaviour nobody has written down.
  4. Add one shinytest2 smoke test: the app starts, the main table populates. This is the safety net for everything that follows.

Phase 1 — Make it testable (weeks 2–4)

  1. Extract business logic into R/fct_*.R functions, one at a time. After each extraction, run the smoke test.
  2. Write testthat tests for each extracted function as you go. Prioritise anything that computes a number a reviewer looks at.
  3. Move the UI and server into R/, split by screen.

At the end of this phase the app still works identically, but the parts that matter are tested.

Phase 2 — Modularise (weeks 5–7)

  1. Convert each screen into a module, one per PR. Start with the most isolated one.
  2. Add testServer() tests per module.
  3. Convert to a package with golem::create_golem() or by hand: DESCRIPTION, NAMESPACE, run_app().

Phase 3 — Harden (weeks 8–10)

  1. Externalise configuration; move secrets to environment variables.
  2. Add logging at boundaries and shiny.sanitize.errors = TRUE.
  3. Review data access: is filtering done at the source, or per-output? Fix any output that could leak beyond the user’s authorisation.
  4. Add session cleanup and check for temp-file accumulation.
  5. Display the version in the UI; stamp it on every export.

Phase 4 — Deploy properly (weeks 11–12)

  1. Containerise, or publish to Connect with a manifest.json.
  2. CI: run tests on every push, deploy on a tag.
  3. Delete the copy-files deployment path so nobody can use it.

Phase 5 — Validation, if required (ongoing)

  1. Formalise the requirements from step 3, give each an ID.
  2. Add requirement IDs to test names for traceability.
  3. Write the design specification from the module structure — which now exists, which is why this step is last.
  4. Scripted UAT, executed and signed.
  5. Change control: every subsequent change linked to a request.

Sequencing rationale

The order is not arbitrary. Tests come before refactoring, because refactoring without tests is just editing. Modularisation comes before packaging, because a package containing one 1,400-line file gains little. Validation comes last, because a validation package documenting a badly structured app is expensive to write and expensive to maintain.

Managing the risk

The app is in use on an active study, so:

  • Never merge to main without the smoke test passing
  • Deploy each phase and let users work with it for a week before starting the next
  • Keep v0.1.0-inherited deployable throughout, and say so in writing
  • Do not change any output during phases 1–2. If a refactor changes a number, that is a bug in the refactor — a snapshot test on the main table is worth adding at step 4 specifically to catch this.

Recap

  • Structure the app as a package; keep business logic in Shiny-free functions
  • Choose the data layer deliberately: in-memory, pooled database, or Parquet + arrow
  • Profile before optimising; the usual fixes are compute-once, cache, and defer to a button
  • One R process serves one thing at a time — move blocking work to a future or a worker
  • Log at boundaries, sanitise errors for users, keep the real message in the log
  • Externalise configuration; secrets only in environment variables
  • Display the version and stamp it on exports
  • Validation requires requirements, tests traced to them, and reproducible environments

Course complete. You can now build a Shiny application that a team can maintain, that survives real concurrency, and that stands up to a review.

Where next:

Back to top