Ad – 728×90
🦉 Advanced OWL

OWL JS Concurrency Model – Fibers, Batching & Async Renders

OWL's rendering model is built around fibers — lightweight units of rendering work that can be paused, resumed, and cancelled. This enables OWL to handle async hooks (like onWillStart) without blocking the UI and to batch multiple state mutations into a single render. Understanding how this works helps you write async code that does not produce race conditions, ghost state updates, or flickering UIs. This lesson explains the concurrency model from the ground up and gives you the patterns to handle it safely.

⏱️ 20 min read 🎯 Advanced 📅 Updated 2026 👁️ Lesson 6 of 6

Batched State Mutations

OWL schedules renders asynchronously via microtasks. Multiple synchronous state mutations within the same call stack produce exactly one re-render:

JavaScript – batched mutations
class Dashboard extends Component {
  state = useState({ count: 0, label: "", color: "blue" });

  updateAll() {
    // THREE mutations — ONE re-render (they are batched)
    this.state.count++;
    this.state.label = `Count is ${this.state.count}`;
    this.state.color = this.state.count % 2 === 0 ? "blue" : "red";
    // OWL schedules one microtask render after this function returns
  }
}
ℹ️
Why microtask batching matters

Without batching, each of those three state mutations would trigger a separate render — three DOM updates instead of one, possibly showing intermediate inconsistent states. OWL's microtask scheduler collects all mutations within the same synchronous frame and applies them in one efficient render pass.

What Are Fibers?

A fiber is an internal OWL concept representing one render pass for a component. When a component needs to re-render, OWL creates a fiber for it. If the render requires awaiting async hooks, the fiber is suspended until those promises resolve. This is what allows onWillStart and onWillUpdateProps to pause rendering without blocking the browser event loop.

Text – fiber lifecycle
State mutation detected
  │
  ├─ OWL creates a new fiber for the component
  │
  ├─ Fiber runs onWillPatch (or onWillStart for first mount)
  │    → if async: fiber suspends, other work continues, fiber resumes on resolve
  │
  ├─ Fiber runs the template render function (synchronous)
  │
  ├─ Fiber diffs vdom vs previous vdom
  │
  ├─ Fiber patches real DOM
  │
  └─ Fiber calls onPatched (or onMounted for first mount)

If a new state mutation arrives while fiber is suspended (async):
  → old fiber is CANCELLED, new fiber starts fresh with latest state
⚠️
Cancelled fibers: the source of async race conditions

If a new render is triggered while an async hook is awaiting, OWL cancels the in-flight fiber. This is correct behavior — it prevents stale renders. But if your async code outside OWL (a setTimeout, a fetch callback) updates state after its fiber was cancelled, the state update will cause a new render. This is safe, but if you are not careful, the "wrong" async result can overwrite the "right" one.

Ad – 336×280

Race Conditions in Async Code

The classic race condition: user triggers two fetches quickly. The first takes longer than the second. The first completes after the second and overwrites the correct result:

JavaScript – race condition (broken)
// ❌ BROKEN — race condition
class SearchResults extends Component {
  state = useState({ results: [], query: "" });

  async search(query) {
    // User types fast: search("he") then search("hello")
    // If search("he") is slower, it will overwrite search("hello") results
    const results = await fetchSearch(query);
    this.state.results = results;  // might be stale!
  }
}
JavaScript – fixed with AbortController
// ✅ CORRECT — cancel previous request before starting new one
class SearchResults extends Component {
  state = useState({ results: [], isLoading: false, query: "" });
  _abortController = null;

  async search(query) {
    // Cancel the previous in-flight request
    this._abortController?.abort();
    this._abortController = new AbortController();

    this.state.isLoading = true;

    try {
      const results = await fetchSearch(query, {
        signal: this._abortController.signal,
      });
      this.state.results   = results;
      this.state.isLoading = false;
    } catch (err) {
      if (err.name === "AbortError") return;   // expected — not an error
      this.state.isLoading = false;
    }
  }

  setup() {
    onWillUnmount(() => this._abortController?.abort());
  }
}

The Request ID Pattern

When AbortController is not available (non-fetch async), use a request ID counter:

JavaScript – request ID pattern
class DataPanel extends Component {
  state  = useState({ data: null, isLoading: false });
  _reqId = 0;  // monotonically increasing request ID

  async loadData(params) {
    const myReqId = ++this._reqId;  // capture THIS request's ID
    this.state.isLoading = true;

    const data = await expensiveOperation(params);

    // Only update state if this is still the latest request
    if (myReqId !== this._reqId) return;  // stale — a newer request is pending

    this.state.data      = data;
    this.state.isLoading = false;
  }
}

Safe Async State Patterns Summary

ProblemSolution
Stale async result overwrites fresh resultAbortController or request ID pattern
State update after component unmounted_isMounted flag or AbortController aborted on unmount
Two fetches running simultaneouslyCancel the previous before starting new (AbortController)
Multiple rapid state mutations causing multiple rendersKeep mutations synchronous — OWL batches them automatically
UI flickers between statesSet loading state synchronously before await; clear it in finally

State Mutations Across Awaits

Mutations on opposite sides of an await are NOT batched — each one is in a different microtask:

JavaScript – batching across awaits
async loadData() {
  this.state.isLoading = true;   // Render 1 (microtask queued)

  const data = await fetchData();  // ← awaiting suspends this function

  // Code below runs in a NEW microtask after await resolves
  this.state.data      = data;    // Render 2 (separate microtask)
  this.state.isLoading = false;   // Batched with line above → still Render 2
}

// Result: TWO renders:
// 1. isLoading = true → shows spinner
// 2. data = ..., isLoading = false → shows data

// This is usually fine and actually desired:
// the user sees the loading spinner while data is fetched.
💡
Two renders across an await is intentional

The first render (before await) shows the loading state immediately. The second render (after await) shows the data. This is the correct UX pattern — the spinner appears right away, not after the fetch. If you want to avoid any intermediate state, set all state properties in onWillStart — OWL awaits it before the first render, so only one render happens with all data ready.

📋 Summary

  • OWL batches synchronous state mutations into a single render via a microtask scheduler.
  • Fibers are the internal unit of a render pass. Async hooks (onWillStart, onWillUpdateProps) suspend the fiber until their Promises resolve.
  • If new state arrives while a fiber is awaiting, the old fiber is cancelled and a new one starts — this prevents stale renders but means your async callbacks must guard against race conditions.
  • Use AbortController to cancel previous fetches when a new request starts.
  • Use a request ID counter when AbortController is not applicable.
  • State mutations across an await create two renders — this is intentional (shows loading spinner then data).
  • Always guard async callbacks that update state against "component unmounted" with an _isMounted flag or abort signal.

🏋️ Exercise

Build a LiveSearch component that demonstrates safe async concurrency:

  1. A text input bound to state.query. On every input event, debounce 300ms then call a search() method.
  2. search() must be race-condition-safe using AbortController. Cancel any in-flight request when a new one starts.
  3. Show a loading indicator while searching. Show "No results" when results are empty. Show the results list when data arrives.
  4. If the query is cleared (empty string), immediately set results to [] and cancel any pending request.
  5. In onWillUnmount, abort any pending request.
  6. Test by typing quickly — only the final result should appear, never an earlier stale result.

FAQ

Can I force OWL to render synchronously? +

In most cases you should not need to. OWL renders within the same microtask queue as Promise resolutions, so by the time your code continues after an await, OWL has already rendered. If you truly need a synchronous paint (e.g., for layout measurements immediately after state change), the browser provides document.body.getBoundingClientRect() which forces a synchronous layout. But manipulate the DOM directly only as a last resort — prefer reading measurements in onPatched.

Why does OWL cancel fibers instead of queuing them? +

Queuing would produce renders with stale intermediate state — the user might see data from a previous query flash before the new data appears. Cancellation ensures that only the most current state ever makes it to the DOM. It mirrors how browsers handle rapid user input: only the last action produces a visible result. The trade-off is that your async code must be designed to handle cancellation gracefully (via abort signals or request IDs).