Production application design
Lesson 11 — R Shiny
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.
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: 200config <- 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
- 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.
- 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.
- Logic in render functions. Nothing is testable, and the same computation is almost certainly duplicated across outputs.
- 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()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)
- 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. renv::init(); renv::snapshot(). Record what actually works today.- Write down what the app does, screen by screen, as a draft requirements list. You cannot test behaviour nobody has written down.
- Add one
shinytest2smoke 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)
- Extract business logic into
R/fct_*.Rfunctions, one at a time. After each extraction, run the smoke test. - Write
testthattests for each extracted function as you go. Prioritise anything that computes a number a reviewer looks at. - 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)
- Convert each screen into a module, one per PR. Start with the most isolated one.
- Add
testServer()tests per module. - Convert to a package with
golem::create_golem()or by hand:DESCRIPTION,NAMESPACE,run_app().
Phase 3 — Harden (weeks 8–10)
- Externalise configuration; move secrets to environment variables.
- Add logging at boundaries and
shiny.sanitize.errors = TRUE. - Review data access: is filtering done at the source, or per-output? Fix any output that could leak beyond the user’s authorisation.
- Add session cleanup and check for temp-file accumulation.
- Display the version in the UI; stamp it on every export.
Phase 4 — Deploy properly (weeks 11–12)
- Containerise, or publish to Connect with a
manifest.json. - CI: run tests on every push, deploy on a tag.
- Delete the copy-files deployment path so nobody can use it.
Phase 5 — Validation, if required (ongoing)
- Formalise the requirements from step 3, give each an ID.
- Add requirement IDs to test names for traceability.
- Write the design specification from the module structure — which now exists, which is why this step is last.
- Scripted UAT, executed and signed.
- 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
mainwithout the smoke test passing - Deploy each phase and let users work with it for a week before starting the next
- Keep
v0.1.0-inheriteddeployable 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:
- Clinical Programming with R — the data your apps display
- Python — Streamlit and Shiny for Python
- R Programming — packages, testing, Git in depth