Forge
1.x
Docs/Forge/The runtime
Open

Reading8 min
Updated5 Aug 2026
Sourcev1/web-client/runtime.mdx

@forge-go/client-core is where everything a hook does actually lives. Generated output holds types and one-line bindings; the store, the tag graph, the query engine and the transports are here, so a runtime defect is fixed by publishing this package rather than by regenerating every repository that consumes your API.

You rarely call it directly. It is worth understanding anyway, because three of its behaviours explain almost everything surprising a normalized cache does.

A response is split in two01

On arrival, entities go into a flat store keyed Type:id. What is left is a skeleton holding references and inline scalars.

GET /orders  →  { items: [{id: 7, total: 99, customer: {id: "c-3", name: "Ada"}}, {id: 8, …}] }

                              normalize

entity store                             query skeleton  (key: listOrders|{})
┌───────────────────────────────┐        ┌──────────────────────────────┐
│ Order:7       {total: 99, …} v4│        │ { items: [→Order:7, →Order:8] │
│ Order:8       {…}            v1│        │   total: 2 }                  │
│ Customer:c-3  {name: "Ada"}  v2│        └──────────────────────────────┘
└───────────────────────────────┘         deps: {Order:7, Order:8, Customer:c-3}

Reading a query rehydrates the skeleton against the store. That indirection is why a PATCH /orders/7 updates the list, the detail page and a sidebar badge with no refetch: all three reference Order:7.

The runtime knows where to descend because the generated entities table told it:

export const entities = {
  Customer: { idField: 'id' },
  Order: { idField: 'id', fields: { customer: 'Customer' } },
  OrderPage: { fields: { items: 'Order' } },
} as const;

OrderPage has edges and no identity — a signpost the walk passes through rather than a record it stores.

What a refetch preserves

This distinction matters the first time you profile a render:

  • Entity identity is preserved. A response re-delivering an unchanged Order:7 writes no new version, so the rehydrated Order:7 is the same object it was. A memo'd row rendering it skips. The same holds for an unchanged Customer subtree beneath it.

  • Container identity is not. A fresh response is a fresh skeleton — new arrays, new wrapper objects — so the list containing those orders is a new array.

The store declines to claim it knows a list's shape is unchanged merely because the entities in it are. The property still does its job, because what a component renders per row is the entity, not the array.

Note

A write whose data is deep-equal to what is stored is not a write: the version does not move and no identity downstream changes. A poll returning the same bytes re-renders nothing.

Tags decide what refetches02

A mounted query carries two kinds of tag in one set: the provides from the manifest resolved against its arguments, and the entity keys normalize reported for its skeleton. Those keys are already spelled Type:id — exactly what a mutation to Order:7 invalidates — so a list that loaded Order:7 is reachable from a write to it with no second index.

When a mutation settles, the runtime intersects its invalidates against the tag index, marks matches stale, and refetches mounted queries only, coalesced into one batch per tick. Unmounted queries stay stale and refetch on next mount. Refetching data nobody is looking at turns a smart cache into a bandwidth complaint.

That is the whole of the difference between the two tag shapes:

  • Order:7 reaches every query that has seen order 7. A field update needs no refetch at all — the record is patched in place and dependents re-render.

  • Order[] reaches queries over the collection. Membership changes need the network, because the server decides who is in a filtered list.

Warning

A tag template that resolves to nothing is skipped. resolveTag returns undefined rather than a partially substituted string, because a tag that quietly becomes Customer: matches no query, fires nothing and reports nothing. It is surfaced as cause.unresolved in devtools — the only place it is visible.

The placement escape hatch03

Refetching after a create is correct and often wasteful: you already have the created order, and it belongs at the top of the list you are looking at. Placement lets the call site say so.

const create = useMutation(useCreateOrder, {
  place: {
    'Order[]': (order, current, args) =>
      args.query?.status && args.query.status !== order.status
        ? undefined // filtered list this order does not belong to: refetch
        : [order, ...current],
  },
});

Returning a list skips that query's refetch. Returning undefined means "I don't know" and falls back to refetching.

That fallback is the point. The runtime never reasons about whether an entity belongs in a filtered or paginated window — that is the Relay connection-directive tarpit — and the application is allowed to decline for the cases it cannot decide. A filtered list whose predicate the callback cannot evaluate returns undefined and gets correct data at the cost of one request.

Two rules worth knowing:

  • All or nothing per query. A query matched by both Order[] and Customer:3 where only the first has a callback still refetches. Placing one while the other is unhandled leaves the query looking updated while being wrong.

  • A callback that throws is reported through onError and treated as undefined. It does not take the rest of the batch with it.

Retries, auth and identity04

  • Retries apply to idempotent methods only by default — GET, HEAD, PUT, DELETE — with exponential backoff and jitter, and no retry on 4xx except 408 and 429. Retrying a POST on a timeout produces duplicate orders.

  • A 401 triggers a single-flight refresh with one retry. Concurrent requests queue behind the in-flight refresh rather than stampeding.

  • The store is partitioned by principal and dropped on identity change. A normalized store keys Order:7 globally with no memory of who fetched it; without partitioning, entities from one session stay addressable in the next. This is a correctness property, and it is a class of defect a response-keyed cache does not have.

Live frames take the same path05

A socket frame is a mutation the client did not initiate. It decodes, matches its binding from the streams table, and applies the declared intent plus any tag invalidation through the same code path a mutation response takes — one implementation, not two.

Two properties that are cheap now and expensive later:

  • Gap recovery. A dropped socket means missed frames, and a reconnected client looks correct while being wrong. On reconnect the runtime invalidates every tag bound to that channel and refetches mounted live queries. Without it, staleness after a closed laptop lid presents as "it just stops updating" and is unfalsifiable from the outside.

  • Write batching. Frames coalesce into one store commit per animation frame. A channel at 200 msg/s must not mean 200 renders.

Using the pieces directly06

Each layer is usable on its own, and the transports are injected rather than reached for — nothing here touches the network by itself.

import { EntityStore, denormalize } from '@forge-go/client-core';
import { entities, ops } from './generated/ops';

const store = new EntityStore();
const { skeleton, deps } = store.write(response, entities, ops.listOrders.rootType);

denormalize(skeleton, store); // the response, rebuilt from the store
ExportWhat it does
normalize(value, entities, rootType?)Pure. Returns {skeleton, records, deps}
EntityStore#write(...)Normalize and commit
denormalize(skeleton, store)Rebuild, with structural sharing
QueryRegistryMounted queries and their tags
InvalidatorTurns a settled mutation into a refetch batch
QueryCacheThe query engine
RestTransportDrives the generated REST client
StreamBinder, SubscriptionManagerLive frames and ref-counted sockets
Warning

Nothing here is copied defensively. Values handed to the store are not cloned, and values read back are the store's own objects. Mutating one in place moves the cache underneath every query referencing it, with no version bump and no re-render.

Known gaps07

Documented by the runtime itself, not inferred:

  • Optimistic overlays are not implemented. The layered design exists; the code does not.

  • SSR revival. A skeleton serializes, but a deserialized one is not recognised as a skeleton, so dehydrate/hydrate are not offered rather than offered half-working.

  • Entity garbage collection. The cache caps queries, but an entity no live skeleton references is still held. EntityStore#evict exists; a policy driving it does not.

  • Field renaming does not reach the hook path. The transport drives the HTTP client below the generated per-endpoint methods that set the codecs. Under the default camel naming this means a hook can return wire-cased fields while the direct REST client returns renamed ones from the same package. With --field-naming preserve and no overrides, no codec table is emitted and the two are exactly equivalent.

  • WebTransport binding is unwritten, though the subscription manager accepts any connection.

See Not yet shipped for the rest.