Skip to contents

Why health check endpoints?

Deployment platforms decide whether your application is working by probing an HTTP endpoint:

A failing liveness probe restarts your container; a failing readiness probe stops routing traffic to it. Shiny has no native concept of a health endpoint, and the common workarounds (wrapping the UI function, adjusting uiPattern, running a plumber sidecar) are easy to get wrong – a misconfigured uiPattern silently 404s every probe, and a health check behind an authentication wrapper bounces probes to a sign-in page.

shinyhealth solves this at the right layer: the app object’s own HTTP handler, where requests can be intercepted before any UI rendering, session creation, or authentication.

Adding a health check

Pipe any Shiny app object through add_healthcheck():

app <- shiny::shinyApp(
  ui = shiny::fluidPage("hello"),
  server = function(input, output, session) {}
) |>
  add_healthcheck()

has_healthcheck(app)
#> [1] TRUE
healthcheck_paths(app)
#> [1] "/health"

The decorated app answers GET /health with a JSON response and passes every other request through to your app unchanged. No uiPattern adjustment is needed, and construction method does not matter – plain shiny::shinyApp(), golem, or apps wrapped by authentication packages like polished all work, because the interception happens after the app object is fully constructed.

Probing the endpoint returns:

$ curl -i http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Type: application/health+json
Cache-Control: no-store

{"status":"pass","timestamp":"2026-09-06T23:00:17Z"}

Health responses

health_response() builds the response objects served from health endpoints. The defaults produce a healthy response:

Two fields are deliberately independent:

  • status_code (the HTTP transport status) is what orchestrators interpret. 200 means healthy; 503 (Service Unavailable) is the standard choice for unhealthy.
  • status (the body field) is a convention for humans and tooling. The default vocabulary ("pass", and by convention "warn" / "fail") follows the IETF health check response format draft, but any value is accepted.

The response defaults follow the relevant standards: application/health+json content type (safe for any JSON consumer per RFC 6839’s +json suffix rule), Cache-Control: no-store so probe responses are never cached, and RFC 3339 UTC timestamps.

health_response(status = "fail", status_code = 503L)

Additional named fields merge into the body, and checks carries arbitrarily nested check results:

health_response(
  version = "1.0.0",
  checks = list(
    database = list(status = "pass", latency_ms = 2.3),
    upstream_api = list(status = "warn", output = "slow response times")
  )
)

The handler contract

A handler is any function that accepts the request object and returns one of:

  1. a health_response()
  2. a plain shiny::httpResponse() (passed through untouched)
  3. a named list, treated as the complete response body – a status_code field, if present, controls the HTTP status
handler <- function(req) {
  list(status = "degraded", status_code = 503L, reason = "cache warming")
}

as_health_response(handler(req = NULL))

If a handler throws an error, the endpoint stays machine-readable: the error is converted into a 503 response with status = "fail" and the condition message under output.

Liveness and readiness endpoints

Registrations stack, so separate endpoints with different handlers are just repeated calls:

app <- shiny::shinyApp(
  ui = shiny::fluidPage("hello"),
  server = function(input, output, session) {}
) |>
  add_healthcheck(path = "/livez") |>
  add_healthcheck(path = "/readyz", handler = function(req) {
    health_response(
      checks = list(database = list(status = "pass"))
    )
  })

healthcheck_paths(app)
#> [1] "/livez"  "/readyz"

A common pattern: keep the liveness handler trivial (the default – if the process can answer, it is alive) and put dependency checks on the readiness endpoint, so a database blip stops traffic routing without triggering container restarts.

Console feedback during development

add_healthcheck()’s verbose argument (default: shiny::in_devmode()) logs each probe hit to the console running the app. Enable devmode during development to watch probes arrive:

shiny::devmode()
shiny::runApp(app)
#> i [shinyhealth] GET /health (2026-09-06T23:00:17Z)

Configuring platform probes

With the endpoint in place, point your platform’s probes at it. Kubernetes:

livenessProbe:
  httpGet:
    path: /livez
    port: 8080
  periodSeconds: 30
readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  periodSeconds: 10

Google Cloud Run (gcloud run services update or service YAML):

startupProbe:
  httpGet:
    path: /health
    port: 8080
livenessProbe:
  httpGet:
    path: /health
    port: 8080

Azure Container Apps (bicep/ARM probes block or the portal):

probes:
  - type: Liveness
    httpGet:
      path: /livez
      port: 8080
  - type: Readiness
    httpGet:
      path: /readyz
      port: 8080

Example app

A runnable example ships with the package:

shiny::runApp(system.file("shiny", "hello", package = "shinyhealth"))

It exposes /health (default handler) and /readyz (custom handler with checks) – open either path in a browser or probe them with curl while the app runs.