Connected resources
Before you start
Some work needs cleanup when the page closes but never sends the LiveView a message: a presence registration, an observer, or an external handle. This guide attaches such a resource to the connected LiveView lifecycle. Use asynchronous work or subscriptions instead when the work produces messages, or when it must be replaced or cancelled while the page stays open.
Acquire a resource in the connected mount
The ConnectedResourcestrait ConnectedResourcesAcquisition and finalization owned by one connected LiveView lifecycle.
capability is exposed through the connected mount branch as
connected.resourcesdef resources: ConnectedResourcesResources owned by the current connected LiveView lifecycle..
Call acquireReleasedef acquireRelease[A](acquire: zio.package.Task[A])(release: A => zio.package.UIO[Unit]): zio.package.Task[A]Acquires a resource and registers its finalizer with the current connected lifecycle.
there and provide a non-failing finalizer. The registration example keeps the
acquired handle in its model:
def mount(ctx: MountContext): Task[Model] = ctx.connection match
case Connection.Disconnected => ZIO.succeed(Model())
case Connection.Connected(connected) =>
connected.resources
.acquireRelease(registrations.register(instanceId))(registrations.unregister)
.map(registration => Model(Some(registration)))View source (documentation/site/src/scalive/docs/examples/ConnectedResourceExample.scala:23-28)
The disconnected branch acquires nothing: the HTTP render ends as soon as the response is sent. Every connected mount, including the one after a reconnect, acquires a new resource.
The capability is intentionally limited to connected mount, either directly or through the route and session initializers below. Do not retain it in the model, a service, or a callback for later acquisition; use a keyed managed API or an explicitly scoped service when ownership changes after mount.
Know when the finalizer runs
The acquisition Task[A] may fail normally. Once it succeeds, Scalive registers
the finalizer before returning the value. That finalizer runs exactly once when
the lifecycle closes, including when the remainder of connected mount or the
initial render fails. An acquisition failure does not run the finalizer, and an
attempt made after lifecycle closure fails before acquisition starts.
The finalizer is a UIO[Unit]: it has no expected typed failure. If an external
cleanup API returns Task[Unit], make the policy explicit by recovering and
logging, retrying within a bound, or deliberately converting the failure to a
defect. Do not leave that decision implicit at shutdown.
ZIO runs acquisition and finalization uninterruptibly so interruption cannot strand a partially registered resource. Keep both effects short and bounded; a blocked acquisition or finalizer also delays lifecycle shutdown.
Cleanup starts after the server observes lifecycle termination. External presence and locks still need leases or expiry for node failure and undetected network partitions.
Know who owns the resource
The owner is the connected LiveView lifecycle, not an application session ID. Two tabs using the same login session acquire independent resources, and closing one tab does not release the other tab's resource. Root and nested LiveViews on one WebSocket also have independent lifecycles; closing the socket releases all of them. Put genuinely session-shared resources in an injected service with explicit reference counting or leases.
Initialize at route or session boundaries
Use withConnectedResources on a route or named live-session builder when an
acquired resource is only a lifecycle side effect, such as a registration or
lease. Keep direct connected-mount acquisition when its result is needed to
construct the page's model, as in the registration example above.
The modifier accepts (Ctx, ConnectedResources) => Task[Result] with
Result <: Unit. Finish an acquisition with .unit when the handle is needed
only by its finalizer. The callback does not enrich typed context or populate a
model, and it does not add a new environment requirement to the builder: capture
services while assembling the application or obtain them from typed context.
This application attaches a registration to a page without changing the page:
object ConnectedResourceRouteExample:
def application[Msg, Model](
registrations: LifecycleRegistrations,
page: LiveView[Msg, Model]
): LiveApplication[Any] =
val registeredRoute = (live / "registered")
.withConnectedResources { (_, resources) =>
resources
.acquireRelease(registrations.register("registered-page"))(registrations.unregister)
.unit
}
Live.router(registeredRoute -> page)View source (documentation/site/src/scalive/docs/examples/ConnectedResourceExample.scala:71-83)
Ctx is the context available where the modifier is installed. Plain route
and session builders supply Any; install after the
mount aspects
that produce the typed context you need. Later aspects or admission preserve the
earlier callback's context through projection, rather than passing it the final
accumulated tuple. A later .context(factory) call does not retroactively type
a plain route's initializer.
Initializers are lazy: neither constructing the builder nor disconnected
rendering runs them. After all session and route admission/context checks succeed,
Scalive runs session initializers first, then route initializers, in declaration
order within each level, before the page's connected mount. Repeated modifiers
append work; they do not replace earlier callbacks. This guarantee is about
mount, not construction: page factories and render compilation can precede the
initializers. Do not rely on registration side effects in a factory or while
building the render graph.
Execution is sequential and fail-fast. A Task failure is a mount failure, not an
authentication rejection or a LiveMountFailure result. Use mount aspects and
admission for controlled authorization outcomes. All initializers and direct
connected-mount acquisitions use the same existing ConnectedResources scope.
If an initializer or the remaining mount/initial render fails, lifecycle closure
rolls back successful acquisitions through their registered finalizers; later
initializers do not run after a failure. Keep acquisition and finalization short
and bounded, just as for direct mount acquisition.
The owner is one connected root lifecycle, not the WebSocket connection, the named live-session group, or a logical application session. A fresh reconnect or route navigation runs the applicable initializers again for the new root. Patches and ordinary messages do not rerun them, and nested LiveViews do not inherit these route/session initializers. Nested views can still acquire their own resources directly during connected mount.
Study the complete example
The connected registration example shows the acquired handle in its initial model, updates ordinary model state without reacquiring, and releases the exact handle when its LiveView closes:
final case class LifecycleRegistration(id: String)
trait LifecycleRegistrations:
def register(owner: String): UIO[LifecycleRegistration]
def unregister(registration: LifecycleRegistration): UIO[Unit]
final class ConnectedResourceExample(
instanceId: String,
registrations: LifecycleRegistrations)
extends LiveView[ConnectedResourceExample.Msg, ConnectedResourceExample.Model]:
import ConnectedResourceExample.*
def mount(ctx: MountContext): Task[Model] = ctx.connection match
case Connection.Disconnected => ZIO.succeed(Model())
case Connection.Connected(connected) =>
connected.resources
.acquireRelease(registrations.register(instanceId))(registrations.unregister)
.map(registration => Model(Some(registration)))
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Check =>
ZIO.succeed(model.copy(checks = model.checks + 1))
case Msg.Reset =>
ZIO.succeed(model.copy(checks = 0))
def view(model: Signal[Model]): HtmlElement[Msg] =
val status = model.map(_.registration.fold("Waiting for connected mount")(_ => "Acquired"))
val handle = model.map(_.registration.fold("Not acquired")(_.id))
div(
cls := "docs-managed-work",
sectionTag(
cls := "docs-managed-work-state",
aria.label := "Connected lifecycle registration",
p("Status", strong(dataAttr("connected-resource-status") := "", status)),
p("Handle", code(dataAttr("connected-resource-handle") := "", handle)),
p(
"Model-only checks",
strong(dataAttr("connected-resource-checks") := "", model.map(_.checks.toString))
)
),
div(
cls := "docs-managed-work-controls",
button(typ := "button", on.click(Msg.Check), "Update model"),
button(typ := "button", on.click(Msg.Reset), "Reset checks")
)
)
end ConnectedResourceExample
object ConnectedResourceExample:
final case class Model(
registration: Option[LifecycleRegistration] = None,
checks: Int = 0)
enum Msg:
case Check, ResetView source (documentation/site/src/scalive/docs/examples/ConnectedResourceExample.scala:10-67)
Run the connected lifecycle registration alongside the documentation site's diagnostic views to watch acquisition and release.
Related tasks
Run one finite task with Asynchronous work.
Emit messages over time with Subscriptions.
Derive the context an initializer reads with Live sessions and mount aspects.
Understand when lifecycles close with Lifecycle, state ownership, and reconnects.