Guide

Deferred rendering

Take a genuinely expensive reply segment off the actor's critical path — the reply returns instantly with a pending marker, and the real render streams to the same actor when ready. Plus `reactive_lazy` for a lazy initial mount.

Profile first — defer is not a performance fix#

An app-side N+1 or a missing eager-load will look exactly like framework lag. Fix the synchronous path first; reach for defer only when a segment is genuinely expensive.

The origin story of this feature is the cautionary tale. A live scoreboard re-rendered inside every debounced keystroke and felt slow — it looked like the framework couldn't keep up. The real cause was an app-side N+1 — 2+N queries per keystroke — fixed with a one-line eager load. No gem change needed, and no defer either.

So before you defer anything, look at the log (or your APM's render.phlex_reactive events) for the queries the segment runs. A rollup that's slow because it loads wrong should be fixed, not hidden behind a shimmer. defer is for the segment that stays expensive after the queries are right — a cross-aggregate rollup, an external-API summary, a render that is simply big.

The honest trade#

Every reply segment normally renders synchronously on the request thread, so one expensive segment stalls the actor's whole interaction — the cheap parts of the reply wait for the slow one.

reply.defer trades slightly worse time-to-full-content (one extra round trip) for much better actor reply latency. It moves cost off the critical path; it never removes it:

  • Actor reply latency — better. The reply returns as soon as the cheap streams render; typing and clicking stay responsive.
  • Time-to-full-content — slightly worse. The deferred segment arrives after its own render plus one extra hop.

If both metrics matter equally to the interaction, defer buys you nothing — render synchronously and keep the simpler wire.

reply.defer#

Chain .defer(component) onto any reply. The canonical shape — a set logger whose cheap volume cell updates instantly while the cross-aggregate session totals render off the critical path:

action :update, params: { weight_kg: :float, reps: :integer, rpe: :float }

def update(weight_kg:, reps:, rpe:)
  @set.update!(weight_kg:, reps:, rpe:)
  reply
    .streams(volume_cell_stream)                 # instant, cheap
    .defer(SessionTotals.new(workout: @workout)) # off the critical path
end

The deferred component must be a reactive component — its signed identity is what the deferred render is rebuilt from, never its state. A bare reply.defer(component) with no prior verb keeps the default self-replace; the deferred segment rides alongside.

Keep-content is the default. While the deferred render is pending, the target keeps its current (stale) content and carries data-reactive-defer-pending plus aria-busy="true". Style the wait with pure CSS:

[data-reactive-defer-pending] { opacity: .5; }

Placeholders and morph arrivals#

reply.defer(comp)                             # keep-content default (mark pending)
reply.defer(comp, placeholder: true)          # comp's deferred_placeholder, or a built-in shell
reply.defer(comp, placeholder: Skeleton.new)  # explicit skeleton (component or String)
reply.defer(comp, morph: true)                # arrival morphs instead of replacing

placeholder: opts into a skeleton that replaces the target immediately: a shell <div> that owns the component's id and carries the pending markers plus a reactive-defer-placeholder class.

  • placeholder: true asks the component — its deferred_placeholder method supplies the shell's content. It may return a Phlex component instance (rendered), an html_safe String (intentional markup, passed verbatim), or a plain String (escaped as data — text, not markup). No method (or nil) means the bare built-in shell, which the CSS class is there to style.
  • placeholder: <component-or-string> supplies the skeleton inline, under the same content contract.

morph: true makes the arrival morph in place instead of replacing — use it when the deferred target holds focusable inputs. The mode rides inside the signed token, so the client can't flip it.

Semantics: transactional, actor-scoped, superseding#

  • Transactional. The defer directive rides the reply, which renders only after the action's transaction committed. A rollback (or a denied action) takes the error path and emits no directive — a deferred render can never leak a change that didn't happen.
  • Actor-scoped. The deferred render reaches only the acting user; it is never echoed to peers. Cross-tab updates keep going through broadcast_*_to, exactly as before.
  • Superseding. A newer action for the same target aborts the in-flight deferred render (the fetch is aborted, the one-shot stream unsubscribed). A fast typist never gets stale totals painted over fresh ones.
  • Arrival is interactive. The deferred HTML carries a fresh signed action token, so the component lands ready for its next action — no dead controls, no re-mount.

Delivery: two lanes, one API#

How the deferred render reaches the actor is a transport decision, not an API one — the Ruby you write is identical on both lanes. Phlex::Reactive.defer_transport picks:

ValueBehavior
:auto (default)Push iff fully capable — pgbus reactive Streams + SignedName + ActiveJob present — else pull.
:fetchAlways pull.
:streamRequest push; degrades to pull with a one-time warning when the capability is absent. Degrade, never break.

Pull (fetch) — the universal lane. The directive carries a purpose-scoped, short-TTL signed token; the client POSTs it to POST /reactive/defer in parallel, off the per-component action queue, and applies the rendered stream when it lands. It's just HTTP — it works on every transport, including plain Action Cable.

Push (stream) — the pgbus lane. The reply mints a durable one-shot pgbus stream (a prdefer_<hex> key), enqueues a Phlex::Reactive::DeferredRenderJob (queue: defer_job_queue, default "default"), and hands the client a signed SSE src. The job renders off the request thread and broadcasts durably; since-id="0" replays the one message even when the broadcast beat the client's subscription — the broadcast-before-subscribe race is closed by construction. The broadcast's payload ends by removing the client's own source element, so the subscription tears itself down with the content it delivered.

Capability is probed live at reply time via Phlex::Reactive.defer_push_capable? — a runtime capability gate, not a version check — so removing pgbus (or ActiveJob) silently falls back to pull. And if the push lane fails mid-reply (signing, enqueue), the segment degrades to a fetch directive rather than 500ing a reply whose mutation already committed.

Point defer_job_queue at a fast queue in production. A deferred segment is a UX-latency render; letting it starve behind heavy jobs defeats the point.

Failure handling#

The shimmer must never lie, and it must never hang:

  • Fetch failure / timeout — the pending markers are cleared, the target gets data-reactive-error="defer" (style it in CSS), and a bubbling reactive:error event fires with kind: "defer" and a retry() function that re-enters the fetch with the same token (still valid inside the TTL).
  • render? false — the defer endpoint returns 204: pending cleared, current content kept. Nothing to render is not an error.
  • Gone record (push lane) — a record deleted while the job sat in the queue broadcasts the cleanup instead: pending markers cleared, subscription torn down. No retry — there is nothing to render.

Lazy initial mount (reactive_lazy)#

The same machinery covers the initial mount — Livewire's #[Lazy] shape. Declare reactive_lazy and the first (page-embedded) render ships only a placeholder shell: the root id, the generic controller, the pending markers, and a defer token on the root. The client's connect() probes the token and fetches the real content through the same pull path a reply directive uses:

class SessionTotals < ApplicationComponent
  include Phlex::Reactive::Component
  reactive_record :workout
  reactive_lazy                    # first render = placeholder shell + fetch on connect

  def deferred_placeholder = TotalsSkeleton.new   # optional
end

Lazy applies only to the page-embedded initial mount. Every render that goes through the reactive machinery — an action reply's self-replace, a broadcast, the defer endpoint or job — renders the real template, so an action on a lazy component never costs two round trips.

Lazy mount is pull-only: a page render has no transaction to defer behind, and enqueueing jobs during rendering would couple rendering to queue infrastructure.

Security#

The defer token follows the same rules as every other token in phlex-reactive — signed identity, never state:

  • Purpose-scoped. Defer tokens are signed under a distinct purpose, so the two token families are non-interchangeable by signature: an action token posted to the defer endpoint is rejected (it must not become a render oracle), and a defer token can never invoke an action (it carries no action grant). Either confusion fails closed as a 400.
  • Short-TTL. A defer token expires after defer_token_ttl (default 120 s) — it only needs to cover the reply-to-fetch gap. A leaked token is a render oracle for exactly one component identity until then.
  • The signature is not authorization. As with actions, auth comes from Phlex::Reactive.base_controller_name (session, CSRF), which applies to the defer endpoint like any reactive request. A component that guards visibility can raise a registered authorization error while being rebuilt or rendered (→ 403) or return false from render? (→ 204, keep content).

Configuration reference#

SettingDefaultWhat it does
Phlex::Reactive.defer_transport:autoLane selection: :auto / :fetch / :stream (see Delivery). Validated at assignment.
Phlex::Reactive.defer_token_ttl120Defer-token lifetime in seconds.
Phlex::Reactive.defer_path"/reactive/defer"Where the pull lane's endpoint is mounted.
Phlex::Reactive.defer_job_queue"default"The ActiveJob queue the push lane's DeferredRenderJob runs on.

See it live on the Deferred totals example — a deliberately slow rollup you can click, with the keep-content, skeleton, and synchronous-baseline variants side by side.