Skip to content

Async Frontends in 2027: Submit Intents, Subscribe to State

When a worker, an agent or another user produces the state, submit an intent and subscribe to it. When the request handler does, keep the query cache.

Ayhan Sipahi Ayhan Sipahi

A query cache treats the HTTP response as the answer to a write. The mutation resolves, related queries are invalidated, and the refetch returns the new state. That sequence is correct only while the request handler produces the state before it returns. When a worker, an agent or another user produces it, the response only acknowledges the request. The refetch then usually reads data that has not changed yet.

For that second class of state, the client should submit an intent and show it as a pending overlay. The answer then comes from a resumable subscription to authoritative state. For handler-produced state, a query cache with invalidate-on-success remains the right default. The dividing question is who produces the state change. My expectation for 2027 is that the subscribed class stops being an exception in most products. Agent workloads make it common, and client collections make it cheaper to adopt.

Engineers who own the client-server contract answer that question per feature, because a subscription has its own cost in authorization and operations.

Where Invalidate-on-Success Still Holds#

The default most teams run is request/response with a query cache. TanStack Query’s guide shows the canonical form: invalidate the related queries in the mutation’s onSuccess callback (opens in new tab). When the handler has already written the row, this is the right design. The response confirms a finished fact and the refetch reads it. Every read also passes through the same endpoint and the same authorization checks. Forms, settings pages and most CRUD screens fit this description, and they should stay as they are.

The strongest objection to going further comes from vendors of the alternative. PowerSync’s engineering blog (opens in new tab) concedes that sync engines are hard to integrate with existing backends. It adds that many require code rewritten to fit their assumptions and can lock a team into one database and query layer. RxDB, a local-first database, maintains a page of local-first downsides (opens in new tab) that includes conflict resolution, permissions, storage limits and client-side data migrations. A team that only wants a faster-feeling screen gets most of it from an optimistic update on the existing cache. None of that infrastructure is needed. For handler-produced state I agree with that argument.

Fire-and-poll deserves the same fairness. For one long job that one screen waits on, a 202 with a status endpoint and a modest polling interval is enough. The MCP Tasks extension (opens in new tab) makes the same choice at the protocol level: “Polling is the default.” A status record read by polling is still authoritative state. It is the simplest form of subscription: it needs only plain HTTP and holds no connection open.

The Gap Between Acknowledgement and Answer#

On an async backend, the write returns 202 as soon as the work is queued. The mutation function resolves on the 202, so onSuccess fires at that moment. The invalidation usually refetches before the worker has written anything. The screen then shows the old state next to a success message.

Teams usually patch this per feature. One feature gets a refetchInterval, a completion flag and a stop condition, and the next feature gets its own copy. None of these loops notices when another user, a background job or an agent changes the same rows. A request/response cache learns about a write only when something triggers a refetch. The client also holds two versions of the same data: the response it awaited, and the state that arrives later by another path.

The contract that removes the patching is small. The write side returns only an acknowledgement. HTTP lets a 202 carry more, such as the current status or a link to a status monitor. Keeping the result out of it is a design choice: the client then reads the answer in one place. The read side is a subscription that can resume after a reconnect.

// By design, the write side returns only an acknowledgement.
interface IntentAck {
  intentId: string; // client-generated, doubles as idempotency key
  acceptedAt: string;
}

// A snapshot is valid up to a known offset.
interface Snapshot<T> {
  data: T;
  offset: string;
}

// The read side is a subscription to authoritative state.
// It starts at the snapshot's offset, so no event falls in between.
// The promise resolves once the stream is live.
interface StateSubscription<T> {
  snapshot(): Promise<Snapshot<T>>;
  subscribeFrom(
    offset: string,
    listener: (next: T, offset: string) => void,
  ): Promise<() => void>;
}

The UI renders the snapshot plus every pending intent that has not reached a terminal state. The subscription starts at the snapshot’s offset, and a reconnect resumes from the last offset the client saw. Without that offset, an event that lands between the snapshot and the subscription is lost. The backend has to guarantee that the snapshot and the event log agree at that offset. Here the offset is exclusive: the snapshot contains every event up to it, and the subscription delivers only later ones. An offset is meaningful only within one stream or partition. The log also has to keep events long enough to replay the gap while the client connects or reconnects.

The intent ID rarely lives on the domain row. A profile update stores the new profile. It does not store which request asked for it. The ID therefore travels as a causation ID on the events the work emits, or in a separate intent-status record. One intent can emit several events, so the overlay is dropped on the terminal one: completed, failed or cancelled. A failure turns the overlay into an error. Because the client generates the ID, the server can safely deduplicate a retried write.

Transport details for a single job, such as SSE, retries and idempotency, are covered in Handling Long-Running API Requests in Web and Mobile Apps. There the client subscribes to the job. Once several screens render the same rows, the more useful subscription target is the data the job changes. The screen treatment of pending and failed states is in Optimistic UI vs Decoupled Flows. The message side, where the intent is a command and the state change is an event, is in Commands vs Events Across Bounded Contexts.

Agent Workloads and Resumable Work#

Agent features make the worker-produced case routine. A generation or a tool run can take minutes, may need user input halfway, and has to survive a page refresh. The protocol layer under agents has already taken this shape. The MCP Tasks extension lets servers “return a durable handle instead of blocking”, and a task moves through working, input_required, completed, failed and cancelled. Tasks is an opt-in extension: client and server both have to declare it, and host support varies. Its reasons for not blocking apply to a browser as well. There is no long-lived connection to hold, and a durable ID allows recovery after a crash. Progress is visible, and input can arrive during the run.

Streamed output has the same requirement. Electric’s Durable Streams (opens in new tab) is an open-sourced protocol. It starts from the observation that SSE and WebSocket connections are ephemeral, so data in flight is lost when a connection drops. The protocol resumes from an offset over plain HTTP. Its announcement gives AI token streams as a motivating case: a dropped stream resumes, and the generation does not start over.

The AI SDK documentation is a useful primary source for what resumption demands from an application, whichever library is in use. With resume enabled, useChat checks for an active stream on mount and reconnects to it (opens in new tab). The same page states that this requires persistence for messages and for active streams. It also explains that a client abort counts as a disconnect in a resumable setup, so stopping a generation needs a dedicated server endpoint.

MCP Tasks and Durable Streams differ in delivery. By default, a Tasks client polls a status record at an interval. A Durable Streams client tails an offset-addressed stream by long-poll or SSE. They agree that one connection held open until the work finishes is the wrong contract. For a frontend, both mean holding a handle and watching state. Once the main interaction of a product works this way, I expect the same contract to spread to its exports, imports and approval flows.

Pending Overlays and Client Collections#

The intent half no longer needs hand-written state. React 19 provides the overlay as a hook, and the useOptimistic reference (opens in new tab) defines it precisely: “Optimistic state only renders while an Action is in progress, otherwise value is rendered.” It then describes the handover: “The optimistic and real state converge in the same render when the Transition completes.” Convex describes its optimistic updates (opens in new tab) as “temporary, local changes to your query results”. They are rolled back when the mutation completes, and the reactive query supplies the real value.

Both describe a pending layer over authoritative state that is discarded when the state catches up. The limit of the React hook is its lifetime. The overlay ends when the Action’s promise settles. On an async backend that promise settles at the 202, so the overlay disappears before the worker has written. Either the Action awaits the subscription, or a store keeps the overlay until a server write with the same intent ID arrives. A bare useOptimistic call leaves that matching to the application, and a sync-backed collection supplies it.

Such a collection used to require a full sync engine. TanStack DB lowers the entry cost. It is at the 1.0 release-candidate stage, and the RC announcement (opens in new tab) says “the API is powerful, production-ready, and stable”. A stable API inside a release candidate is a reasonable basis for a pilot feature, and a weaker one for a whole product. The adoption path is incremental. A collection can be fed by existing REST endpoints through TanStack Query, with live queries and optimistic writes on top. Local persistence, offline mutations and real-time sync can be added later. A team can therefore start one feature on a collection without replacing its backend. That first step is still refetch-based. The feature moves onto a subscription when a sync source feeds the collection.

Full engines have matured too, with caveats. Rocicorp’s status page (opens in new tab) lists Zero as generally available. InfoQ’s report on the 1.0 release (opens in new tab) describes a community reaction that is “positive on developer experience but more cautious on production readiness”. The concerns it collects include Postgres-only support, unsynced views, bundle size and missing server-side rendering.

A Decision per Feature#

The decision starts at the producer of the state and reaches tooling only afterwards.

Request handler, before it returns

Worker, agent or another user

No

Yes

No

Yes

Who produces the state change?

Request/response with a query cache, invalidate on success

Intent plus subscription to authoritative state

Do several features read the same changing data?

Per-feature resumable stream or job status

Client collection with live queries, fed by the existing API

Are offline writes or multi-actor edits core?

Collection plus stream

Sync engine, with its authorization and conflict cost

Each branch below the default adds a cost. A per-feature stream needs a resumable endpoint, an intent ID on every write, and storage for streams that are still running. It fails when a stream never emits a terminal event, because the overlay then stays pending with no exit. A client collection is cheap to operate while the existing API feeds it. It is still a second data layer in the client, and it reconciles only if the server write carries an ID the client can match, such as the intent ID or a transaction ID.

A sync engine adds a replication tier and moves authorization. Reads no longer pass through endpoint handlers, so access rules must be expressed where data is replicated. Electric’s auth guide (opens in new tab) recommends two patterns: authorize shape requests in a proxy, or issue shape-scoped tokens through a gatekeeper endpoint. Revocation needs a plan as well. When access is withdrawn, an open subscription has to be closed or narrowed. Otherwise it keeps delivering rows the user may no longer read. Offline support makes this harder. A device cannot learn that it lost access until it reconnects. The handling of locally cached rows therefore has to be decided before offline writes are enabled. On AWS, AppSync can provide the subscribe half: Triggering AppSync Subscriptions From Outside AppSync shows authoritative state pushed to clients from outside the request path.

Whether a move worked shows in a few counts. Polling loops and manual invalidateQueries calls tied to async writes should decrease. The time from intent submitted to overlay dropped, at p50 and p95, is the latency users experience as completion. The share of overlays that roll back or never reconcile is the correctness signal. Stream resume success after reconnect covers the transport.

Mistakes in a Partial Move#

The 202 means the request was accepted. A UI that shows success at that point reports a result that does not exist yet. The overlay should stay until the subscription shows a terminal event for the intent ID.

A finished workflow does not mean a finished result. A consumed queue message or a completed state machine execution shows that the steps ran. The business state may still be waiting on another system. The client should watch the business state, because that is what the user asked to change.

Invalidate-on-success left in place against an async writer keeps refetching too early. The invalidation belongs on the state event that carries the result.

A stop button that only aborts the client request does not stop a resumable stream. The server sees a disconnect and the generation continues, so cancellation needs its own endpoint. That endpoint takes the intent ID and is idempotent. The cancellation then arrives through the subscribed state, like a completion.

A sync engine adopted for one async feature brings a replication tier and a new authorization model. A stream endpoint already covers that problem. The engine becomes reasonable once several features share subscribed data, or once offline writes are a product requirement.

Row and filter decisions that move into client code weaken authorization. Which tables and which rows a user may sync is decided on the server, in the proxy or in the token.

The Boundary Between Cache and Subscription#

Request/response with a query cache and invalidate-on-success holds wherever the handler produces the state before it returns. That still describes most forms and CRUD screens. The override applies when a worker, an agent or another user produces the state. Within the override, polling a status record or a per-feature stream is enough for one job. A client collection fits several features that read the same data. A sync engine is justified when offline writes or multi-actor editing are core to the product. A practical first step is to list every onSuccess invalidation that follows a 202 and move the feature with the most polling code first.

References#

Related posts