Data Fetching
WebJs gives a component four ways to get data into the page. This is the canonical guide to which one to reach for, and the anti-patterns to avoid. The headline is bare-await async render: a component fetches its own server data into the first paint, co-located, with no page orchestration.
The default: async render()
Make a component's render() async and call a 'use server' action directly. Writing await makes the function async; WebJs awaits a promise-returning render() automatically on both the server and the client. There is no flag.
// (a) blocking async render: real data in the first paint, the common case
class UserProfile extends WebComponent({ uid: String }) {
async render() {
const u = await getUser(this.uid); // real fn at SSR, RPC stub on the client
return html`<h3>${u.name}</h3>`;
}
}
UserProfile.register('user-profile');
SSR awaits the render, so the resolved DATA is in the first paint with no fallback. A JS-off client reads it (a progressive-enhancement UPGRADE over a client-fetched Task, which shows nothing without JS). getUser is isomorphic: the real function during SSR, an RPC stub on the client.
The three concerns are decoupled (do not conflate them)
- SSR always blocks by default. The data is in the first paint, no fallback markup. This is the PE-safe baseline.
- The client re-fetch default is stale-while-revalidate. A prop / dependency change re-runs
async render(); the current content stays until the new render resolves (no blank, no flash). renderFallback()is the optional re-fetch loading UI. Shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming.
Streaming a slow region with webjs-suspense
A bare async render() blocks the first byte. For a SLOW region where that wait hurts, wrap it in <webjs-suspense> to stream it. This is the ONLY way to show a first-paint fallback.
// (b) webjs-suspense-wrapped slow component that streams (first-paint fallback)
html`
<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
<user-profile uid="42"></user-profile>
<user-activity uid="42"></user-activity>
</webjs-suspense>
`;
The fallback flushes on the first byte; the resolved content streams in. Multiple boundaries fetch concurrently (no server waterfall). One boundary groups several components under one fallback, and the boundary .fallback wins over a contained component's renderFallback(). On a client-router navigation the boundary streams progressively too (the shell with the fallback applies immediately, then the data streams in). A throwing component inside a boundary is isolated to its own error state while siblings stream.
The re-fetch loading state: renderFallback()
// (c) renderFallback() as the client re-fetch loading state (re-fetch on a prop change)
class UserActivity extends WebComponent({ uid: String }) {
renderFallback() { return html`<div class="skeleton h-24"></div>`; }
async render() {
const items = await getActivity(this.uid);
return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
}
}
Define renderFallback() only when stale content during a re-fetch would mislead. It is a prop-aware method (not a static field), so it can branch on the component's current state. Task cannot cover this case: a Task renders its pending state at SSR, losing the first-paint data, and you cannot wrap a signal around your own await inside render().
Errors are isolated by default
// (d) the no-op error case: isolation works WITHOUT renderError()
class Report extends WebComponent {
async render() { return html`<pre>${await getReport()}</pre>`; }
// no renderError() needed: a thrown await is isolated to THIS component,
// siblings render, the page does not blank. Add renderError() only to
// customize the error UI.
}
A thrown await getData() (or any render throw) renders a component-scoped error state while siblings render, never bubbling to the route error.ts. The default surfaces the tag and message in dev and renders a silent empty element in prod (no leak). This is a per-route-error-boundary experience at the component level, with no per-component routes.
A bare async leaf ships zero JS (elision)
A bare async render() with no other client signal (no @event, no non-state reactive prop, no signal, no lifecycle hook, no <slot>, light DOM) produces its complete output at SSR, so the framework ELIDES its module from the browser. That drops the JS download AND the redundant on-hydration re-fetch. A content or docs leaf that fetches and displays is the common shape. The first paint is byte-identical with or without the module. Only the stale-while-revalidate refresh-on-load goes away, which is moot for request-stable data (the usual case).
Two cases always ship, even bare. static shadow = true ships because Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run attachShadow. static interactive = true is the explicit author override that forces the module to ship even when it would be elided, for interactivity static analysis cannot see (a dynamically-computed tag string, or a :defined rule in an external stylesheet). Any independent signal (an @event, a non-state prop, a signal, renderFallback(), an interactive child it imports) ships the module as usual.
A shipping async component does not re-fetch on hydration (seeding)
When an async component DOES ship (it has an interactivity signal, so it cannot be elided), WebJs still avoids the redundant hydration fetch. Each 'use server' action result invoked during the SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call. So const u = await getUser(this.id) runs once, on the server, and the client's first render reuses the result with no network round-trip. A later refetch (a prop or signal change, a new argument) misses the seed and goes to the server as normal, so the seed never serves stale data.
It is automatic and needs no code: the same async render() you already wrote. There is no source transform and no build step (the capture is a transparent server-side facade over the action module), so what you write is what you see in the browser source tab. It is on by default; disable it with "webjs": { "seed": false } in package.json or WEBJS_SEED=0, in which case the client re-fetches on hydration (the stale-while-revalidate default hides the flicker). Streamed <webjs-suspense> regions are not seeded, since their data resolves after the first byte.
HTTP-verb actions: cacheable reads and tag invalidation
An action declares its HTTP semantics through reserved sibling exports, the same way a page declares export const revalidate. The function stays a plain export async function (one per file); a method export picks the verb, and a GET can be cached.
// modules/users/queries/get-user.server.ts
'use server';
export const method = 'GET'; // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; absent => POST
export const cache = 60; // seconds, or { maxAge, swr, public }; default private
export const tags = (id) => ['user:' + id];
export async function getUser(id) { return db.user.find(id); }
// modules/users/actions/update-user.server.ts
'use server';
export const invalidates = (id) => ['user:' + id];
export async function updateUser(id, data) { /* ... */ }
The call site never changes (await getUser(7)). A GET rides its args in the URL, is CSRF-exempt, and is served with Cache-Control + an ETag, so a repeat read within the window comes from the browser cache and a stale one revalidates with a 304. A mutation sends a body, is CSRF-protected, and on completion its invalidates tags evict the matching server cache and tell the client to refetch the affected reads. A wrong request method is a 405. It is additive: an action with no method stays a POST, exactly as before. The cache defaults to private; { public: true } shares the response across users keyed only by URL, so use it only for data identical for every visitor, never a per-user read.
A public REST endpoint is a route.ts that imports and calls the action; validate is a boundary concern (the RPC endpoint and the route handler), not a direct server-to-server call.
Cancellation is automatic: a superseded async render() (a newer prop or signal change while a fetch is in flight) aborts the previous render's in-flight action fetch, and on the server an action can read the request's AbortSignal via actionSignal() to stop expensive work when the client disconnects.
An action can declare export const middleware = [mw1, mw2] (each async (ctx, next) => result): the chain runs around the action on the RPC and REST boundaries, short-circuits (an auth middleware returning an ActionResult instead of calling next()), and accumulates context the action reads via actionContext().
Streaming results: return a stream or async generator
When an action returns a ReadableStream, an async iterable, or an async generator, the framework streams each chunk over the single RPC response instead of buffering the whole thing. The call site gets back an async iterable to for await, and each chunk arrives as it is produced. This is for token streams (an LLM response), progress events, or a large result set you want to render incrementally.
// modules/ai/actions/stream-answer.server.ts
'use server';
export async function* streamAnswer(prompt) {
for await (const token of llm.complete(prompt)) yield token;
}
// in a component
for await (const token of await streamAnswer(q)) {
this.text.set(this.text.get() + token); // renders incrementally
}
Detection is purely on the return value, so any verb can stream and there is no config export to set. Each chunk round-trips through the serializer (a Date / Map / BigInt inside a chunk survives). Back-pressure is respected, and the stream cancels when the client disconnects or the render is superseded (the same AbortSignal wiring as above), so a server generator stops producing. A streamed result is never cached or seeded; a mid-stream error surfaces as a throw from the iterable (wrap the for await in try/catch). For a slow region you want behind a fallback on the FIRST paint, reach for <webjs-suspense> instead; streaming RPC is for an imperative stream a component consumes after an interaction.
Decision rules
- Server data knowable at request time. Fetch it IN the component with
async render(). Co-located, no prop-drilling, data in the first paint. The default, simplest case. - Slow server data where blocking the first byte hurts. Wrap in
<webjs-suspense>to stream it. Deliberately, for slow regions, not by default. - Client re-fetch where stale content would mislead. Add
renderFallback()for a loading state during the re-fetch. - One fallback for a whole section, or a context-specific fallback. Use
<webjs-suspense>around several components (override plus grouping). - Genuinely client-only data (depends on a click, viewport, localStorage, or live updates, not needed in the first paint). Use
Task/ signals plus an RPC action. - Errors. Do nothing by default. The framework isolates a failed async component automatically. Add
renderError()ONLY to customize the error UI. - A pure fetch-and-display leaf. A bare
async render()with no other signal is ELIDED automatically (no module download, no on-hydration re-fetch), and its first paint is unchanged. Addstatic interactive = trueonly to force a component the analyser would elide to ship, andstatic shadow = truealways ships.
Deferred or self-refreshing regions: webjs-frame with webjs-suspense
Everything above puts data in the FIRST response (blocking or streamed). Some regions instead need to load or refresh independently of a full-page navigation, which is the one thing a page or layout cannot do, because a page/layout re-renders only at the route level (the whole route, on navigation). The unit for an independent region is <webjs-frame>, a server-rendered, URL-addressable sub-region that loads and reloads on its own and ships zero component JS (its content is server HTML, swapped in by the framework). It is the WebJs answer to a leaf that behaves like an RSC server component, re-rendering through a server round-trip rather than shipping a module.
webjs-frame is webjs's take on Turbo Frames (from Hotwire Turbo), so the mental model and most muscle memory transfer directly. A frame is a lazy, URL-addressable region; a link or form targeting it swaps only that region; loading="lazy" defers it to viewport entry. If you know <turbo-frame>, you already know <webjs-frame>.
Reach for a frame, not a page/layout, when the region:
- Refreshes on its own (a dashboard widget, a "load more", a filtered list) without reloading the page. Drive it with a
data-webjs-frame="<id>"link or form, or by changing itssrc; the server re-renders just that route and the frame swaps the result in. No component module ships. - Is below the fold or expensive and should NOT hold the first response open. Use
<webjs-frame id src loading="lazy">, so the first response ships fast and small and the frame self-loads its URL on viewport entry (a second request). This is the deliberately-deferred case, distinct from streaming. - Is URL-addressable (a tab panel, a detail pane) that maps to a route.
Combining the two. A frame defers a region to a SECOND request; <webjs-suspense> streams slow data WITHIN a response. They compose: a frame whose route is itself slow can wrap that data in <webjs-suspense>, so the frame defers the load (lazy, on viewport) and the slow data then streams in behind a fallback inside the frame. A comments section that is both below the fold AND slow is the canonical case.
// app/post/[id]/page.ts, defer the comments to a lazy frame
html`
<article>...the post (in the first paint)...</article>
<webjs-frame id="comments" src=${`/post/${id}/comments`} loading="lazy"></webjs-frame>
`;
// app/post/[id]/comments/page.ts, the frame's route streams its slow data
html`
<webjs-frame id="comments">
<webjs-suspense .fallback=${html`<p>Loading comments…</p>`}>
<comment-list post-id=${id}></comment-list>
</webjs-suspense>
</webjs-frame>
`;
The right way: point the frame's src / data-webjs-frame at a route.ts or page that renders the region server-side; wrap genuinely-slow data inside it in <webjs-suspense>; use loading="lazy" for below-the-fold; and keep PE-critical content in the first paint (a frame src is JS-dependent, so a no-JS client sees only what was rendered into the frame). One caveat: when a framed route streams, the frame's byte-saving subtree extraction is skipped (the full page renders server-side and the client slices out the region), so you trade some wire bytes for the streaming.
Surgical single-element updates and live channels: webjs-stream
A frame or a region swap redraws a whole region. That is too coarse for "append ONE comment", "remove ONE row", "bump a count", or "insert a toast". For those, <webjs-stream> applies a per-element update declared as plain HTML: a <webjs-stream action target> wrapping a single <template>, where action is one of append / prepend / before / after / replace / update / remove and target is an element id. The element self-applies on connect and removes itself, so it needs no per-app DOM code.
webjs-stream is webjs's take on Turbo Streams (from Hotwire Turbo); the action set mirrors <turbo-stream>, so that muscle memory transfers directly. The same applier serves two delivery paths: a content-negotiated <form> response (the client router asks for the stream MIME only on a JS-driven submit, so a JS-off form still gets a normal render), and a live channel, where renderStream(message) in a connectWS handler applies a broadcast()ed payload. So chat, notifications, and presence reuse the same grammar.
// server: append one comment, fan it out to other viewers, degrade for no-JS
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
import { escapeText } from '@webjsdev/core';
export async function POST(req, { params }) {
const c = await addComment(params.id, await req.formData());
const html = stream.append('comments', '<li>' + escapeText(c.text) + '</li>');
broadcast('post:' + params.id, html);
return acceptsStream(req) ? streamResponse(html) : Response.redirect(new URL('/post/' + params.id, req.url), 303);
}
Reach for <webjs-stream> when the change is a single element inside an otherwise-unchanged region, or when a live channel pushes incremental updates. Use a region swap or a <webjs-frame> reload when a whole region changes; those are not the tool for one row.
Which primitive when (the decision boundary)
- async render: co-located server data in the FIRST paint (the default).
- webjs-suspense: slow data that should still be in the first response, streamed behind a fallback.
- webjs-frame: a region that loads / refreshes INDEPENDENTLY of a full navigation (self-refresh, lazy below-the-fold, URL-addressable), server-rendered, zero component JS. Turbo Frames.
- webjs-stream: a SURGICAL single-element update (append / remove / replace one node) or a live-channel push. Turbo Streams.
Anti-patterns
- Do NOT prop-drill server data through layers when the leaf component can fetch it itself.
- Do NOT put
await getData()in a page / layout function if it can live in a component: page / layout fetches run SEQUENTIALLY (a route-level waterfall), while component fetches run in PARALLEL via boundaries. - Do NOT fetch in
connectedCallback/Taskfor data that is knowable server-side (that yields a fallback-then-RPC, not first-paint data). - Do NOT use a
<webjs-frame src>for primary component data. It is a client second request (a waterfall), not first paint. Frames are for URL-addressable / deferred regions only. - Do NOT expect
renderFallback()to affect the first paint or trigger SSR streaming. It is the CLIENT re-fetch loading state. To show a first-paint fallback, wrap in<webjs-suspense>. - Do NOT add
renderError()on every component. Isolation is automatic. - Do NOT wrap in
<webjs-suspense>when a componentrenderFallback()already suffices (one layered model). - Do NOT bolt on a needless signal (or
static interactive = true) to "force" a bare async leaf to ship. It is correctly elided and progressive-enhancement-safe already. Reach for the override only when the analyser genuinely cannot see a component's interactivity.
See Streaming & Suspense, Components, Lifecycle Hooks, and Error Handling.