DEV Community

Cover image for Suspense is a symptom
Matteo Antony Mistretta
Matteo Antony Mistretta

Posted on

Suspense is a symptom

This post is part of a series on Inglorious Web. You can start from the beginning, or read it standalone — the argument here is self-contained.


Inglorious Web didn't start as a React alternative. It started as a game engine.

I wanted to build a cool JavaScript game engine — something that could handle hundreds of entities updating at 60 frames per second, with clean state management, deterministic event handling, and no magic. The state manager that emerged from that work was general enough that I started using it for web apps too. And once it was powering web UIs, the React rendering layer started feeling like the wrong tool: too heavy, too coupled, too opinionated about who owns state. I replaced it with lit-html and followed the logic all the way through.

Game engines have been solving high-frequency state updates with heterogeneous objects for decades — under far stricter performance constraints than any web UI. They never needed a component hierarchy. They never needed to fire REST requests from inside a render function. And they never needed <Suspense>.

That last point is the one I want to pull on.


What Suspense Actually Does

<Suspense> was introduced by React and has since been adopted by SolidJS, and more recently Vue. The premise is elegant: wrap a part of your component tree in a boundary, declare a fallback, and the framework shows the fallback until all async resources inside the boundary have resolved. No cascading spinners, no layout shifts, no manual coordination of loading states across components.

Here's the SolidJS version, straight from their docs:

const MyComponent = () => {
  const [profile] = createResource(async () => { /* fetch */ })

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <div>{profile()?.name}</div>
      <div>{profile()?.email}</div>
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

The key detail: createResource reads data inside the component, inside the render function. The component itself initiates the fetch. Suspense catches the pending state at the nearest boundary and holds back the DOM until the resource resolves — pre-rendering nodes speculatively so there's less work to do when data arrives.

It's genuinely clever engineering. But notice what it's engineering around.


The Problem Suspense Solves — And Where It Comes From

The coordination problem Suspense solves exists because of a specific architectural choice: data fetching is coupled to rendering. The component declares what it needs, initiates the fetch, and renders based on the result — all in one place. When multiple components do this independently, you get cascading loading states that need a boundary mechanism to coordinate.

Game engines don't have this problem. Not because they don't fetch data asynchronously — they do it constantly. Loading screens stream level geometry. Multiplayer games make network calls every frame. Asset pipelines fetch textures and audio in the background. But the loading state is just state. A loading screen is an entity. The transition from loading to loaded is an event. The render function reads whatever state the entity currently holds.

Game engines solve this differently. Loading screens, asset streaming systems, scene readiness checks — these all exist, and they handle async coordination constantly. But they do it by reading state that reflects loading progress, not by letting the rendering layer initiate the loading itself. The render function reads whatever the entity currently holds. The loading logic lives elsewhere.

This is the same architectural instinct that Inglorious Web carries into web UI development.


How Inglorious Web Handles It

In Inglorious Web, fetching data and rendering data are two separate concerns handled by two separate mechanisms. handleAsync manages the fetch lifecycle:

import { handleAsync } from '@inglorious/store'

const types = {
  profile: {
    ...handleAsync('fetchProfile', {
      start(entity) { entity.status = 'loading' },
      async run(id) {
        const res = await fetch(`/api/profiles/${id}`)
        return res.json()
      },
      success(entity, data) {
        entity.data = data
        entity.status = 'success'
      },
      error(entity, err) {
        entity.status = 'error'
        entity.error = err.message
      },
    }),
  },
}
Enter fullscreen mode Exit fullscreen mode

The render function reads state:

render(entity) {
  if (entity.status === 'loading') return html`<div>Loading...</div>`
  if (entity.status === 'error') return html`<div>Error: ${entity.error}</div>`
  return html`<div>${entity.data.name}</div>`
}
Enter fullscreen mode Exit fullscreen mode

There is no <Suspense> boundary. There is no resource being read inside the render function. The fetch is triggered by an event — store.notify('profile:fetchProfile', userId) — and the result lands in the store. The render function is a pure function of whatever state the entity currently holds. It doesn't know or care how that state got there.

This isn't a workaround for the absence of Suspense. It's a different model where the coordination problem doesn't arise.


Suspense Is a Symptom

The spread of Suspense across frameworks — React, then SolidJS, now Vue — tells you something. Frameworks that allow async resources to be read inside the reactive graph tend to require Suspense-like coordination to manage the consequences. Not all signal-based frameworks go this route — Svelte notably doesn't — but the pattern is clear enough: when data fetching becomes part of the reactive rendering graph, a boundary mechanism follows.

This is the pattern that recurs across the whole series. TanStack Query exists because component-local state creates a data fragmentation problem. Suspense exists because reactive rendering creates a loading coordination problem. Each tool is a genuine solution to a real problem — but the problem is architectural, not universal. Change the architecture, and the problem disappears.

Game engines figured this out not by being clever, but by being constrained. When you're updating thousands of entities at 60 frames per second, you can't afford to let the rendering layer own business logic. State is state. Rendering is rendering. The separation isn't a best practice — it's a survival requirement.

Web UIs operate under far looser constraints, which is probably why the coupling was allowed to creep in. Components that fetch their own data are convenient. Reactive resources that update automatically are ergonomic. Suspense boundaries that coordinate loading states are elegant. Each step feels like an improvement. But the complexity accumulates, and each layer of machinery exists to manage the consequences of the previous one.

Inglorious Web went the other way — not because I planned it, but because the game engine origin forced the separation from the start. The state manager didn't know anything about rendering. The renderer didn't know anything about data fetching. When I applied that architecture to web UIs, there was nothing left for Suspense to do.


When You Might Still Want Suspense

Being honest here matters. If you're using a framework where data fetching is coupled to rendering — which describes most of the mainstream options — Suspense is the right tool. It's a well-designed solution to a real problem in that context.

And there's one scenario where even an entity-based architecture needs something like Suspense: lazy-loaded entity types. If your app loads type definitions on demand — for route-based code splitting, plugin systems, or large config-driven UIs — you need a way to signal "this type isn't ready yet, show a fallback."

Inglorious Web's router already handles this. Here's what the app-level render function looks like when supporting lazy-loaded routes:

export const app = {
  render(api) {
    const router = api.getEntity("router")

    return html`
      <main>
        ${when(router.error, () =>
          html`<div>Route not found: ${router.path}</div>`
        )}
        ${when(router.isLoading, () => html`<div>Loading...</div>`)}
        ${when(!router.error && !router.isLoading, () =>
          api.render(router.route)
        )}
      </main>
    `
  },
}
Enter fullscreen mode Exit fullscreen mode

And the lazy type itself is just a plain type definition loaded on demand:

// lazy-type.js
export const lazyType = {
  render() {
    return html`
      <h1>Lazy Loaded Route</h1>
      <p>Check your network panel: this route was loaded on demand!</p>
    `
  },
}
Enter fullscreen mode Exit fullscreen mode

Notice what's happening: router.isLoading is just state on an entity. The fallback is just a conditional in the render function. The lazy type loads when the route is navigated to, and the transition from loading to loaded is an event. There's no boundary mechanism catching a thrown promise — the app-level render function is the boundary, and it's explicit rather than implicit.

This is a legitimate use case for Suspense-like coordination, and Inglorious Web handles it natively through the same mechanisms it uses for everything else. The difference is that you can see exactly what triggers the loading state, exactly what the fallback is, and exactly when the transition happens. Nothing is implicit.


The Pattern

Every post in this series has landed on the same observation from a different angle: the tools we reach for most often exist to manage consequences of architectural choices, not to solve fundamental problems. Remove the choice, and the tool becomes unnecessary.

REST didn't build a better session cache — it eliminated the need for one. The entity store doesn't build a better query cache — it makes the fragmentation that requires one impossible. And Inglorious Web doesn't need a better Suspense — because when data fetching lives in the store and rendering is a pure function of state, there's nothing to suspend.

The game engine didn't solve this problem. It never had it — because the constraint of rendering thousands of entities at 60 frames per second made the coupling between fetching and rendering unthinkable from the start. The separation wasn't a design principle. It was a survival requirement.

Web UIs operate under far looser constraints, which is probably why the coupling was allowed to develop. But looser constraints don't mean the coupling is free. It just means the cost shows up later, in the form of tools like Suspense that exist to manage consequences rather than prevent them.

Docs · Repo

Top comments (0)