Skip to content

Instantly share code, notes, and snippets.

@PatrickJS
Last active June 17, 2026 19:41
Show Gist options
  • Select an option

  • Save PatrickJS/8e239bbbbc57c4cff7fc5a4d9f177b06 to your computer and use it in GitHub Desktop.

Select an option

Save PatrickJS/8e239bbbbc57c4cff7fc5a4d9f177b06 to your computer and use it in GitHub Desktop.

They overlap in vocabulary, but they are different machines.

Qwik Components Qwik components are resumable component units.

A Qwik component can have:

component$()
useSignal()
useStore()
useTask$()
useVisibleTask$()
onClick$ / QRL handlers
routeLoader$ data
serialized/resumed closure state
lazy-loaded code chunks
render output

The important part is that Qwik’s component is part of the reactive execution graph. It can be rendered on the server, paused, serialized into HTML metadata, and later resumed in the browser without full hydration.

So Qwik lifecycle/handlers are deeply tied to:

QRL lazy loading
optimizer/compiler
serialized closures
task tracking
component render scheduling

Async Framework Components Our “components” should stay as scoped fragment factories.

They do not rerender. They do not hydrate. They do not become a persistent render graph.

const ProductCard = defineComponent(function ProductCard(props) {
  const selected = this.signal(false);

  this.handler("select", function () {
    selected.set(true);
  });

  this.on("attach", function () {
    // DOM exists
  });

  return html`
    <article signal:class="${["card", { selected }]}">
      <h2 signal:text="${props.title}"></h2>
      <button on:click="${this.handler(function () {
        selected.set(true);
      })}">
        Select
      </button>
    </article>
  `;
});

A component invocation creates:

a scope id
scoped signals
scoped handlers
scoped effects
scoped lifecycle callbacks
static HTML
bindings that point at signals
cleanup records

After that, updates happen through signals and bindings, not by rerunning the component function.

Async Components We should be careful with this phrase.

I would not make “async components” mean:

const Product = async function () {
  return html`...`;
}

as the main model. That starts pulling us toward render scheduling and suspense ownership.

Instead:

components are sync fragment factories
async work belongs in asyncSignal, server(), partials, or route loaders

Good:

const Product = defineComponent(function Product({ productId }) {
  const product = this.asyncSignal("product", async function () {
    return this.server.products.get(productId.value);
  });

  return html`
    <article async:boundary="${product.id}">
      ${this.suspense(product, {
        loading: () => html`<p>Loading...</p>`,
        ready: () => html`<h1 signal:text="${product.id}.title"></h1>`,
        error: () => html`<p signal:text="${product.id}.$error.message"></p>`
      })}
    </article>
  `;
});

Avoid as the core model:

const Product = defineComponent(async function () {
  const product = await this.server.products.get(...);
  return html`...`;
});

That can exist later as server-only partial sugar, but not Layer 1 component behavior.

Handlers Qwik handlers:

<button onClick$={async () => { ... }} />

are compiled into QRL lazy references. The browser can load the handler later on demand.

Our handlers:

<button on:click="cart.add">

resolve through a registry:

handler: {
  "cart.add"() {
    this.signals.update("cartCount", n => n + 1);
  }
}

Inline component handlers:

<button on:click="${this.handler(function () {
  selected.set(true);
})}">

should compile/runtime-register to a scoped handler id:

component.Product.1.handler.2

Then cleanup unregisters it when the component scope is destroyed.

So our handler model is:

registry id -> function
scoped id -> function
command chain -> sequential awaited commands
server command -> proxy/registry call

No QRL required in Layer 1.

Lifecycle Qwik:

useTask$        -> before render / tracked reactive task, server + browser rules
useVisibleTask$ -> client visible/mounted eager task
cleanup         -> when task reruns or component unmounts

Our lifecycle should be smaller:

this.on("attach", fn);
this.on("visible", fn);
this.effect(fn);

Meaning:

attach  -> after DOM inserted, scanned, and initial bindings flushed
visible -> after root element becomes visible
effect  -> signal-driven side effect, scoped cleanup

Also HTML pseudo-events:

<div on:attach="measure">
<div on:visible="analytics.view">

But docs should prefer component lifecycle on the root element / component context.

Main Difference Qwik lifecycle can participate in render/resume semantics.

Our lifecycle is DOM activation lifecycle.

Qwik: component rendered/resumed, task graph runs
Async: fragment inserted/scanned, bindings/lifecycle run

What We Should Spec I’d lock this in:

1. Components are synchronous scoped fragment factories.
2. Async data inside components uses asyncSignal + suspense templates.
3. Server-rendered async HTML uses partials/routes, not client component rerenders.
4. Handlers are registry/scoped ids, not serialized closures.
5. Lifecycle is attach/visible/effect, scoped and scheduler-phased.
6. Component cleanup cancels scoped scheduler jobs, unregisters scoped handlers/signals, and releases DOM bindings.

Short Comparison

Concept Qwik Async Framework
Component resumable reactive unit scoped fragment factory
Async data routeLoader/useAsync/useAsyncComputed signals asyncSignal/resource signal
Handler QRL lazy function registry/scoped function id
Lifecycle task/visible task in render/resume model attach/visible/effect in DOM activation model
Update component/render graph signal bindings
Cleanup component/task cleanup scope cleanup
Build optimizer central optional higher layers later

So yes, we have components, handlers, and lifecycle too. But ours should remain more like:

HTML fragment + scoped registry + signal bindings

while Qwik is:

compiled resumable component/task graph

We (async) are “resuming” too, but not in the same technical sense as Qwik.

I’d split the word into two meanings:

Qwik Resumability Qwik resumes an execution graph.

It SSRs the app, then serializes enough metadata so the browser can continue later without hydrating the whole tree:

component boundaries
QRL lazy handler references
captured lexical scopes
reactive subscriptions
task/useTask relationships
store relationships
visible/event wakeup points

So Qwik’s scheduler has to answer:

Which serialized task/component/store edge wakes up?
Do we need to lazy-load code first?
Which resumed subscriptions are valid?
Can this task run before that component/store/router state is ready?

That is conceptually heavier.

Our Resumability We resume a protocol, not an execution graph.

We SSR or stream HTML, then the browser does:

read signal/cache snapshot
scan HTML attributes
bind signal paths to DOM
attach handler ids
swap streamed boundaries
rescan inserted HTML

The browser is not continuing a paused component function. It is activating declarative HTML against registries.

So our resume model is more like:

HTML + registry ids + signal paths + snapshots + boundary ids

not:

paused component closures + serialized reactive graph

Example Qwik:

button click wakes QRL
QRL lazy-loads code
runtime restores lexical/context state
scheduler runs associated task/component graph

Async Framework:

button on:click="addToCart"
loader resolves handler id
handler reads signals/server/cache
scheduler flushes affected bindings

Streaming Same distinction.

Qwik streaming/resume has to keep component/task/event-handler readiness coherent.

Our streaming only needs:

boundary id
sequence number
HTML fragment
signal/cache patch
scope cleanup
scan + flush

That is why our scheduler can be smaller.

So Are We Resumable? Yes, but I’d call it:

protocol resumability
HTML activation resumability
boundary resumability

Qwik is closer to:

execution resumability
component/task graph resumability

Both avoid classic hydration, but they avoid it differently.

Why This Matters Our scheduler does not need to schedule component reruns, QRL wakeups, or serialized closure restoration.

It only needs to schedule:

signal bindings
effects
lifecycle callbacks
asyncSignal refreshes
route/boundary swaps
stream patches
scope cleanup

That is the main reason our way should stay simpler.

They’re similar in goal, but different in what they’re attached to.

Same Core Idea Both treat async data as reactive state:

async work -> value/error/loading-ish state -> UI updates when it changes

So conceptually:

const product = asyncSignal(...)

and Qwik v2 route loaders becoming AsyncSignals are pointing at the same shape: async data should not be a separate loader object bolted onto reactivity. It should be reactive data.

Qwik v2 release notes say route loaders are now AsyncSignals, loader errors live in error, and reading value throws that error. They also added route-loader cache controls like expires, poll, and allowStale. See Qwik release notes: Qwik releases.

Main Difference Qwik AsyncSignals live inside Qwik’s component/resumability model.

Our async signals live inside a registry/runtime protocol.

Qwik:

routeLoader$ / useAsync$ / useAsyncComputed$
-> signal used inside component render/task graph
-> compiler/runtime/QRL/scheduler know how to wake/render/resume

Async Framework:

signals.asyncSignal("product", fn)
-> registered signal id
-> HTML binds to product paths
-> async:boundary chooses loading/ready/error templates
-> scheduler flushes bindings/effects

API Difference Qwik style is more component/hook shaped:

const product = useProductDetails();
return <h1>{product.value.title}</h1>;

Qwik docs show routeLoader$() producing a signal consumed inside components: routeLoader docs.

Our style is registry/boundary shaped:

const product = this.asyncSignal("product", async function () {
  return this.server.products.get(productId.value);
});

return html`
  <article async:boundary="${product.id}">
    ${this.suspense(product, {
      ready: () => html`<h1 signal:text="${product.id}.title"></h1>`,
      error: () => html`<p signal:text="${product.id}.$error.message"></p>`
    })}
  </article>
`;

Error Semantics Qwik v2:

error stored in error
reading value can throw if failed

Our model should stay:

value is value/undefined
$error is stable error object
$status is "idle" | "loading" | "ready" | "error"
$loading is boolean

I prefer ours here because HTML bindings should not have hidden throw behavior.

<p signal:text="product.$error.message"></p>

is simpler than “reading value may throw.”

Dependency Tracking Qwik tasks explicitly track:

useTask$((ctx) => {
  ctx.track(() => page.value);
});

Docs say useTask$ reruns when tracked signals/stores change: Qwik nutshell.

Our async signals currently track registry reads during the sync start of the function:

this.asyncSignal("product", async function () {
  const id = this.signals.get("productId"); // tracked
  await delay(10);
  return this.server.products.get(id);
});

That is simpler, but less explicit. Long term we probably want both:

this.asyncSignal("product", async function () {
  const id = this.signals.get("productId");
  return this.server.products.get(id);
});

and maybe later:

this.asyncSignal("product", {
  deps: ["productId"]
}, async function () {});

Caching Qwik v2 route loaders are more built-in here. Release notes mention loader expiry, polling, stale behavior, eTags, and route-loader JSON caching.

Our current model is lower-level:

this.cache.getOrSet("products:sku-1", () => {
  return this.server.products.get("sku-1");
});

That is intentionally simpler, but we should add ergonomic sugar later:

this.asyncSignal("product", this.cache("products.get", async function () {
  return this.server.products.get(this.signals.get("productId"));
}));

Rendering Difference This is the big one.

Qwik AsyncSignal affects component rendering.

Our asyncSignal affects HTML bindings and boundaries.

Qwik has to coordinate:

async signal
component render
tasks
QRL lazy loading
route loader cache
resume state

We coordinate:

async signal
registered signal paths
DOM bindings
boundary templates
scope cleanup

That’s why ours should remain easier to reason about.

Practical Verdict Qwik v2 AsyncSignals are broader and more integrated.

Our asyncSignal should be narrower and more explicit:

id-based
registry-backed
abortable
statusful
boundary-aware
server-aware
cache-aware
scheduler-flushed
no thrown promises
no component rerender dependency

So yes, inspired by the same direction, but ours is simpler because the output target is static HTML + bindings, not a resumable component execution graph.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment