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.
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 replacingplaceholder: 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: trueasks the component — itsdeferred_placeholdermethod supplies the shell's content. It may return a Phlex component instance (rendered), anhtml_safeString (intentional markup, passed verbatim), or a plain String (escaped as data — text, not markup). No method (ornil) 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:
| Value | Behavior |
|---|---|
:auto (default) | Push iff fully capable — pgbus reactive Streams + SignedName + ActiveJob present — else pull. |
:fetch | Always pull. |
:stream | Request 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 bubblingreactive:errorevent fires withkind: "defer"and aretry()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
endLazy 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.
Load on first use (reactive_lazy on:)#
Plain reactive_lazy defers server time but still makes its request on connect. on: defers the request itself — "load this panel the first time it opens", with no action, no loaded flag and no app JavaScript:
class ItemsPanel < ApplicationComponent
include Phlex::Reactive::Component
reactive_lazy on: "panel:opened" # a DOM event on (or bubbling into) the shell
# reactive_lazy on: :visible # the shell first scrolls into view
# reactive_lazy on: { visible: "200px" } # …with an IntersectionObserver rootMargin
def id = "items-panel"
def view_template = ul(id:, **reactive_attrs) { Current.user.items.each { li { it.name } } }
def deferred_placeholder = ItemsSkeleton.new
endThe shell makes no request on page load. The first matching event (or the first intersection) materializes it once; later events are no-ops, because the real render never contains the shell or its trigger. :visible watches the shell itself with an IntersectionObserver and starts the request directly — no DOM event is involved, so a nested visible shell cannot trigger its ancestor (each shell loads only when its own observer intersects). A positive rootMargin grows the observed region, so the shell loads a little before it scrolls into view; a negative one shrinks it, so the shell must scroll further in before it loads. In an engine without IntersectionObserver it materializes right after connect.
Mechanics: an on: shell carries the component's identity token — the same one actions use, with no expiry — and a framework-owned __materialize trigger bound once, instead of a defer token. So it works on a page left open far longer than defer_token_ttl. __materialize rides the action endpoint (CSRF and auth from your base controller as usual), runs no action and opens no transaction, and renders through the same step as the defer endpoint: a registered authorization error from from_identity/render → 403, render? false → an empty stream (the shell stays). It is instrumented as defer.phlex_reactive on the server; on the client it travels the action pipeline, so have_reactive_requests counts it under kind: :action. It answers 403 for any component that is not reactive_lazy(on:), and action :__materialize is refused at declaration.
Failures. A failed materialize (network drop, 4xx/5xx) marks the root data-reactive-error and fires reactive:error, as for any action. It is not retried on its own: an event shell's trigger is bound once and a visible shell stops observing when its request starts. The shell is re-armed by the next Turbo morph of the root (below), by event.detail.retry() from a reactive:error listener, or by the next page render.
Give the shell the real root's tag. The shell is a <div> unless you say otherwise. If the real root is a <ul>, <tr>, <li>…, declare it: reactive_lazy on: "x", tag: :ul. Besides keeping the markup valid, it is what lets a Turbo morph treat the shell and the real render as the same element — everything in the next paragraph depends on that. With mismatched tags Turbo swaps the node instead: a fresh shell is mounted, and an already-open event panel shows its skeleton until its event fires again.
Turbo morphs. A page-refresh morph (or a method="morph" stream) rewrites the root in place and runs no Stimulus lifecycle, so the client handles it itself, whenever the root itself is morphed (a morph of something inside it changes nothing here):
| The root before the morph | After the morph it is… | What happens |
|---|---|---|
| real content | real content | nothing — no request |
| real content | the shell again | it re-materializes at once, one request per morph: it was loaded and the morph wiped it (for an event shell, the panel is already open and its event won't fire again) |
| the shell (never triggered, or a failed load) | still the shell | it is re-armed: :visible observes again and loads on the next intersection; an event shell accepts its event again — this is a failed load's retry path, one attempt per morph |
| the shell, load in flight | still the shell | nothing — the in-flight reply fills it; never a second request |
The cost of refresh morphs. A full page render ships the shell, so every page-refresh morph turns each loaded on: component back into its shell, and each one reloads: one __materialize request per loaded component per refresh (with matching tags; a mismatched shell is swapped in instead and waits for its trigger). That is bounded by how often the page refreshes — the client never triggers it on its own — but a page that refreshes on every broadcast pays it every time. To opt a component out, put data-turbo-permanent on its real root (it needs its id, which a reactive root has): Turbo then skips the element when morphing, so the loaded content stays and no request is made. The trade is the usual one for a permanent element — the refresh no longer updates it; its own actions and broadcasts still do. A cache: component (next section) reloads from the browser's HTTP cache instead, so the refresh costs it no request at all.
Reuse across page views (reactive_lazy cache:)#
A fragment that is the same for a viewer on every page — a menu's items, an account summary, a "recent items" list — is otherwise fetched and rendered again on every page view: the defer fetch is a POST, and every render signs a fresh, expiring token, so the browser has nothing it could reuse. cache: turns the real render into a GET the browser may keep privately:
class AccountMenu < ApplicationComponent
include Phlex::Reactive::Component
reactive_lazy cache: { max_age: 10.minutes } # combine with on: / tag: freely
def id = "account-menu"
def reactive_cache_viewer = Current.user&.id # who this render is for
def reactive_cache_version = Current.user&.shortcuts_updated_at # busts the URL when it changes
def view_template = Current.user.shortcuts.each { |s| a(href: s.url) { s.label } }
endThe shell carries a stable signed URL in data-reactive-defer-src — /reactive/fragment/<signed id>?v=…&u=… — instead of a per-render token, and the client GETs it (no CSRF token: rendering has no side effects). The endpoint renders the real template through the same authorization step as the defer endpoint and answers:
| Header | Value |
|---|---|
Cache-Control | max-age=<n>, private — never public. <n> is the declared max_age, capped by Phlex::Reactive.fragment_cache_max_age_limit (1 hour). |
ETag | derived from the rendered body alone, so a stale copy revalidates with a 304 |
Vary | Cookie — unless reactive_cache_viewer returns a non-blank value (below) |
What the shell costs, and when deferring pays off. The shell stays in the page in place of the fragment, so deferring makes the page lighter only when the fragment is clearly bigger than the shell. A cache: shell carries the URL and no token: the id is the identity encoded once plus a 128-bit MAC, v is 11 characters and u 22. Measured for a record-less component with a one-character placeholder (production settings, SHA256 verifier; the issue #306 spec asserts the last row):
| Shell | Markup | Gzipped |
|---|---|---|
reactive_lazy (defer token) | 384 B | 311 B |
reactive_lazy on: (identity token) | 472 B | 340 B |
reactive_lazy cache: | 245 B | 197 B |
reactive_lazy on:, cache: | 373 B | 256 B |
on:, cache: + reactive_cache_viewer + reactive_cache_version + reactive_dormant | 418 B | 291 B |
Signed state or a record GlobalID makes the id longer, and your placeholder adds its own bytes. So: defer a fragment that is well over the shell — roughly 1 KB gzipped or more — or one that is slow to render. A dozen links (100–250 B gzipped) costs less inline than its shell does; render it in the page.
Every other response is no-store: a 4xx, a render? false (204), and anything your base controller answers before the endpoint runs (a 401, a redirect to sign-in). A cacheable reply carries no Set-Cookie: an unchanged session is not re-issued, and a request during which any callback — before, around or after the action — changed the session or wrote a cookie keeps that write and is answered no-store instead. The consequence: an app whose base controller writes the session or a cookie on every request (an activity timestamp; Devise's timeoutable does this) gets no-store on every fragment reply. That is the safe outcome, but the cache is then off — exempt the fragment route from that filter if you want it. The flash counts too: a flash set during the request reaches the next one, a pending flash that your code read during the request is swept, and either makes the reply no-store (flash.now does not; a 304 that consumed a flash is no-store with a Set-Cookie too). The endpoint itself never reads the flash, so a notice waiting for the next page view survives a fragment load. A CSRF token a filter hands out (form_authenticity_token in a callback) is treated the same way: the request that mints it keeps it and is no-store; once the session has a token, later replies are cacheable again. (With the cookie CSRF storage strategy the token cookie is written every time one is handed out, so such a filter turns the cache off.) With a rolling-expiry session (expire_after), a cacheable fragment reply does not renew the session, since it does not re-issue it.
The ETag is the body only: the controller's etag { } blocks are not mixed in (Rails' own flash etagger would load, and so consume, a pending flash). Vary a cached fragment through reactive_cache_version / reactive_cache_viewer instead.
The app can tighten the policy from a base-controller filter: no_store, expires_now or a max-age of 0 makes the reply no-store; a shorter max-age (expires_in 5) is kept; and must-revalidate is kept. It cannot loosen it — public, s-maxage, stale-while-revalidate and a longer max-age are dropped, back to private, max-age=<n>. In a header set by hand, every max-age=<integer> is read and the smallest wins (a negative one counts as 0); a malformed value (max-age="5", max-age=abc, Max-Age = 5) is ignored, so the component's applies, and a lookalike such as x-max-age=1 is read too (which only tightens). One asymmetry when fragment_cache_max_age_limit is 0: a before filter that sets a policy gets max-age=0, private, while an after filter that merely adds a directive gets no-store — both fail closed.
This guarantee covers the controller's callbacks, the flash and the CSRF token. A session write made outside them — in Rack middleware, a routing constraint, or a Content-Security-Policy nonce generator that writes the session (Rails calls it after the controller returns; the stock request.session.id.to_s generator only reads) — is out of sight, and is lost on a cacheable reply either way: one made before the controller runs is already part of the starting state (so the session looks unchanged and is not persisted), and one made after it is simply dropped. Keep such writes off the fragment route.
Who a copy is for: Vary: Cookie, or reactive_cache_viewer. A private cache must never show one viewer's fragment to the next viewer of the same browser. There are two ways that is guaranteed:
Without reactive_cache_viewer (default) | With reactive_cache_viewer | |
|---|---|---|
| What tells viewers apart | the Cookie header (Vary: Cookie) | the URL (u, an opaque keyed digest of the value you return) |
| Reused while | the browser sends the identical cookies | the URL is unchanged, for max_age |
| With Rails' cookie session store | reused within a page (a refresh morph), not across page views — the store issues a new session cookie on every response | reused across page views |
| With a server-side session store (stable cookie) | reused across page views, until any cookie changes | reused across page views |
reactive_cache_viewer is a promise: the identity, the version and this value together decide the render. Return what the render depends on — the user id, or [Current.user&.id, Current.tenant.id, I18n.locale]. The endpoint re-computes it in the requesting session and makes the reply cacheable only when the URL names that same viewer; a URL that names someone else (a page rendered before sign-out, a hand-built URL) still renders for the current session, but is no-store. Anything else that changes the render and is not in the cookie either — a locale taken from Accept-Language, a feature flag — belongs in reactive_cache_viewer or reactive_cache_version.
A blank viewer names nobody. nil, false, a blank string, a collection (Array, Hash, Set, Struct, …) with any blank part at any depth ([Current.user&.id, locale] when signed out), or an object whose to_param is blank is never turned into a shared "anonymous" key: for that render the component falls back to the default mode — no u, Vary: Cookie. So Current.user&.id is safe to return as is; signed-out visitors just get the cookie-keyed behaviour.
Return a value that is unique per viewer. The value is expanded like a cache key (to_param), so 0, "0" and [0] are one viewer, as are [1, 2] and "1/2" (and ["a/b", "c"] and ["a", "b/c"]); true is the same viewer for everyone, and so is an unsaved record (User.new has the cache key users/new for every guest — return nil for guests instead). A user id, or an Array of ids, is the intended shape — a viewer is an identity, never a collection to enumerate. Two caps apply: a collection of more than 32 parts, or a value that takes more than 64 values in total to walk (nested collections count every level — a 22-entry Hash is already 67), names nobody. So does a Range, an unsized Enumerator, and a value that raises when turned into a key. Each falls back to the default mode and is logged once per component, with the component's name and the reason. A Time is unreliable (Rails expands it through to_a).
u is a keyed digest (derived with the same secret that signs the tokens): it cannot be reversed to the value, and nobody can compute another viewer's u without your secret. What it cannot hide is the URL itself from someone using the same browser profile — the max_age cap and Clear-Site-Data: "cache" on sign-out are the bound there.
reactive_cache_version is optional and only busts the URL (v, a digest): return an updated_at, a counter, a record. The endpoint never reads v or u for the render — they shape the browser's cache key and nothing else.
With on:. reactive_lazy on: "panel:opened", cache: { max_age: 10.minutes } is "load on first open, reuse across page views": no request on page load; the trigger GETs the fragment instead of POSTing __materialize; on a later page view the first open is answered from the browser cache. The same goes for a refresh morph — real content morphed back into the shell re-materializes from the cache, so the per-refresh request described above disappears for a cached component.
One difference from a plain on: shell: with cache: the load runs on the defer lane (a module-level GET), not the action pipeline. reactive:before-dispatch still fires first and can veto it (with detail.action "__materialize"), but the busy markers are the defer ones (data-reactive-defer-pending), and a failed load emits reactive:error with kind: "defer" and a retry() — where a plain on: shell waits for the next morph. A vetoed load behaves the same on both lanes: the event's once binding is spent, so the shell stays as it is (aria-busy included) and ignores later events until the next Turbo morph of the root re-arms it.
A fragment renders as soon as it arrives. Turbo applies a <turbo-stream> one animation frame after its turbo:before-stream-render. A defer-lane reply (a reply.defer fetch, a reactive_lazy load, a cached fragment) does not wait for that frame: once every turbo:before-stream-render listener has run, the client calls the event's detail.render itself, stream by stream, in the same task. The event is still Turbo's own — preventDefault() cancels it and a wrapped detail.render runs as usual — so a warm open from the browser cache lands in the same frame as its response. This relies on Turbo dispatching that event while the stream is appended, as Turbo 8 does; a Turbo that dispatches it later renders on its own, a frame later, as before. Action replies and broadcasts keep Turbo's timing.
With reactive_dormant. An on: + cache: shell goes dormant like any on: shell: the event wakes the root and the load is the cacheable GET. The fragment itself always renders awake — the client that fetches it is loaded by then — so the replaced root needs no second wake. A cache: shell without on: ignores dormancy, exactly like a plain reactive_lazy shell: it has to mount to fetch on connect.
What a cached fragment must not contain.
- A CSP nonce. A
nonce="…"on an inline script or style in the fragment is replayed from the cache and will not match the page's nonce. (It is wrong uncached too: the render uses the off-request view context, not this request.) Keep nonce-bearing inline scripts and styles out of a cached fragment. - A CSRF token. A
form_with/form_authenticity_token/csrf_meta_tagsin the render embeds a token that would outlive its session in the cache. The endpoint detects a field or meta namedauthenticity_token,csrf-tokenor your controller'srequest_forgery_protection_token(any quoting) and serves that renderno-store, with a warning in the log, rather than cache it — so the component keeps working, just uncached. It is a name match: a token placed elsewhere (data-csrf="…") or under an entity-encoded name is not detected, so don't put one there. (A token minted inside the render comes from the gem's off-request view context, as on every reactive endpoint, so it never touches this request's session.) Reactive triggers are unaffected: the client reads the CSRF token from the page'scsrf-tokenmeta tag at request time.
Nested lazy components are safe, with one thing to know: the fragment is a real render, so a reactive_lazy child inside it — whatever its own on: / cache: — renders its template eagerly, as part of the parent's fragment: no shell, no expiring defer token in the cached copy, but also stored under the parent's URL, viewer key and max_age. The child's own cache:, viewer and version are inert there, so the parent's reactive_cache_viewer / reactive_cache_version must cover whatever the child varies on. The child's render? and authorization still run: a child that denies makes the whole fragment a 403, no-store.
Limits.
- The fragment id carries the identity (encoded once, plus a 128-bit MAC), so the URL grows with the component's signed state. Keep
reactive_statesmall on a cached component (a record-backed one carries only its GlobalID). Ids minted before #306 (the verifier's token wrapped in Base64) still verify; their old 32-hexuno longer matches, so such a URL renders but is answeredno-store. - Within
max_agethe browser answers without asking the server — a revoked permission or a sign-out is not seen until the copy expires. Keepmax_ageshort for anything sensitive, and sendClear-Site-Data: "cache"on sign-out. - If you move the endpoint (
Phlex::Reactive.fragment_path), tell the client:<meta name="phlex-reactive-fragment-path" content="…">in the layout's<head>(a copy in the body is ignored). The client only ever fetches adata-reactive-defer-srcthat resolves to this origin's fragment endpoint. A refused URL is logged (naming the meta tag) and falls back to a GET of the same id under the fragment path the client knows, kept out of the HTTP cache (cache: "no-store"); when that fails too it is a failed load withretry(). A refused URL whose last segment cannot be an id fails at once withreactive:error(reason: "refused-url"). Acache:shell never falls back to a POST: it carries no token. If the client's defer module cannot load, itsreactive:error(kind: "feature") is the signal — show your empty state.phlex_reactive:doctorflags a custom path whose meta tag is in no layout. - The reply must be the fragment: a response that was redirected, or is not a turbo-stream, is a failed load (
reactive:error), never rendered. - The route answers
GET(andHEAD: a 200 with the policy headers and an empty body); every other verb is a 404. - A
no-store200 may still carry anETag: stock Rails'Rack::ETagadds one. Nothing stores it. - Only
Cache-ControlandVaryare managed. AnExpiresorSurrogate-Controlheader anafter_actionadds is left alone. - A filter that raises (a
RecordNotFoundturned into a 404 page by Rails) produces a response this endpoint never sees, with noCache-Controlat all — browsers do not store those by default. - In system tests the request counter counts
fetch()calls, so a cache hit still counts (askind: :defer). To assert "no network request", read Resource Timing: an entry the cache answered hastransferSize === 0.
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 returnfalsefromrender?(→ 204, keep content). reactive_lazy(on:)uses the identity token, not a defer token. By opting in withon:, a component lets its identity token fetch its real render through the action endpoint — even a component that declares no actions. That is the same render a plain lazy shell's defer token fetches, minus the TTL, so treat the render as reachable by anyone holding the page and authorize inside it (raise a registered error, orrender?false).__materializeis refused (403) for every component that didn't opt in withon:. Anon:shell that also declarescache:carries no identity token: it loads only through its fragment URL.__materializeis a read, and skips the action wrappers. Like the defer endpoint, it does not runPhlex::Reactive.around_actions(rate limits, audit logs, tenant wrappers), theverify_authorizedcheck, or the pgbus connection-id scope — and unlike a defer token, the token that reaches it never expires. Anything those wrappers enforce for this component (a tenant scope, a rate limit) must be enforced by your base controller or insidefrom_identity/the render itself.reactive_lazy(cache:)adds a GET that renders, and nothing else. The fragment id is MACed under its own purpose (128 bits, keyed byPhlex::Reactive.verifier) with no expiry. It names no viewer, but like the identity token it carries the component's signed state or record GlobalID — signed, not encrypted, and now in a URL (server logs, browser history of fetches), so keep secrets out ofreactive_state. An identity or defer token does not resolve at the fragment endpoint (400), a fragment id does not resolve at the action or defer endpoints (400), and a component that did not declarecache:is not reachable there at all (404). The GET runs no action, noaround_actionswrapper and no transaction, and reads no parameter for the render — like__materialize, treat the render as reachable by anyone holding the page and authorize inside it. Authorization runs on every request that reaches the server; what it cannot do is recall a copy the browser already holds, which is whymax_ageis capped and every error isno-store.
Configuration reference#
| Setting | Default | What it does |
|---|---|---|
Phlex::Reactive.defer_transport | :auto | Lane selection: :auto / :fetch / :stream (see Delivery). Validated at assignment. |
Phlex::Reactive.defer_token_ttl | 120 | Defer-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. |
Phlex::Reactive.fragment_path | "/reactive/fragment" | Where the cacheable-fragment GET endpoint is mounted (<path>/:id). |
Phlex::Reactive.fragment_cache_max_age_limit | 3600 | The longest max_age (seconds) a reactive_lazy cache: component is answered with. |
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.