Stop Asking the Client What to Do
The default way to handle "sometimes a client has to do something" in a web, mobile or embedded app is to expose the state and let the client figure out what to do. This is wrong. Not wrong as in inelegant - wrong as in actively producing the bugs and refactoring nightmares teams keep shipping.
There's a better shape. I call it Task Architecture. I've shipped it at RampEx and ZAR. Stripe runs a version of it on every Connect account. Airbnb, Shopify, and Yelp ship variants of it as Server-Driven UI (SDUI). But none of those places name the discipline that makes it work, and the discipline is the whole point.
This piece is my attempt to articulate that discipline.
This piece is my attempt to articulate that discipline.
The problem
It's common for product folks, designers, and developers to think of their applications as a collection of sequential flows. A user submits their email, completes an account info form, accepts the terms and conditions, and lands on the dashboard. Each flow has an entry point (the sign-up button), a series of steps, and an exit point (the dashboard). For applications with a small number of one-time flows, you can build this with a handful of endpoints for form submissions and a view or component per step. You'll be fine.
The trouble starts the first time the model has to bend, and it usually does. Two failures of the model come up over and over, and they're really two halves of the same underlying mismatch: flows are the wrong unit of organization for things the user needs to do. Flows are temporal and bound to entry points. Obligations are stateful and entry-point-agnostic. Try to express the latter using the vocabulary of the former and you get the two problems below.
- Client-side inference. The client has to ask the server about specific bits of state in order to decide what to render next - does this user need to accept TOCs? Have they verified their phone? Have they completed their profile? Each question demands its own answer channel.
- Out-of-flow obligations. The same user-facing obligation needs to be raised in multiple contexts and across time, not just inside the one entry-point flow where it was first introduced. A TOC acceptance isn't owned by the sign-up flow. It just first appeared there.
Both of these are where developers reach for solutions that quietly deteriorate the maintainability and quality of the codebase, especially when the server and client are separate applications communicating through an API.
Let's look at each.
Client-side inference
When a developer needs to know whether to show a specific view based on server-side state, the most common urge is to add a dedicated endpoint for it:
user submits email → complete account info form → GET /account/needs_toc → accept terms and conditions (maybe) → land on dashboard
In a complex application with dozens or hundreds of potential states, this approach produces an equal number of single-purpose endpoints that expose implementation details as public contracts. Every "should I show X?" question becomes a permanent part of the API surface.
For developers with the sensibility not to proliferate endpoints, the other common solution is to bolt one-time state onto resource endpoints - burdening every request for a resource with unrelated concerns:
GET /account
{
id: 1,
name: "bob",
has_accepted_toc: false,
has_completed_profile: false,
needs_phone_verification: true,
// ... and so on, forever
}Whichever variant you pick, a second-order problem shows up underneath both: the client starts caching these values. Once has_accepted_toc has been read, the client doesn't want to re-fetch it on every navigation, so it gets stashed somewhere - a store, component state, a context provider, localStorage:
// On app load
const account = await fetch("/account");
accountStore.set(account);
// Later, deep in some route guard
if (!accountStore.get().has_accepted_toc) {
navigate("/accept-toc");
}The moment that value is cached, you've opened the door to a whole class of bugs. Stale flags tell the client to skip steps that still need be to complete. They tell it to re-prompt for something the user already did, because a different tab or device updated the server-side state and this client never heard about it.
Multiply that across web, iOS, and Android, each with its own cache life-cycle and no clean invalidation path when server states change. You end up writing cache-busting logic, version stamps, and "refresh on focus" hacks to paper over the fact that you cached something you shouldn't have been tracking on the client in the first place.
The three variants above all have the same root cause: the client is being asked to infer behavior from server-side state, so the server has to expose that state, and the client has to track it. The exposure compounds. So does the tracking.
Out-of-flow obligations
The steps in a flow often represent work the user will need to redo later. Continuing the example: when the Terms and Conditions are updated, every existing user needs to re-accept them. The developer now has two options, both unappealing.
The first is to build a separate path for re-accepting the new TOC, distinct from the one used during sign-up. This duplicates client responsibility. The two implementations will drift apart over time.
The second is to decompose the sign-up flow into reusable parts and figure out how to invoke them outside the sign-up context. This usually means a wrapper component that runs on every app load, hits an endpoint to figure out whether the TOC needs accepting, and conditionally shows itself. The component also needs to know where to send the user afterward - and that depends on where they came from:
const account = await fetch("/account");
const isInSignupFlow = route.matches("/signup/*");
<AcceptTOCComponent
showUnless={account.has_accepted_toc}
onAccept={() => {
if (isInSignupFlow) {
navigate("/signup/profile");
} else {
navigate(route.previous ?? "/dashboard");
}
}}
/>The "reusable" component isn't really reusable. It has to be taught about every flow that can invoke it. Add a third entry point - a re-acceptance prompt triggered from settings - and the conditional grows another branch. The component accumulates routing knowledge that has nothing to do with its own domain.
The downsides are the ones above, plus a new one: the client is now bombarding the server with one-time concerns on every load. As soon as the application grows to where the client is making decisions based on dozens of disconnected states - accepted TOCs, subscription paid, feedback required, profile incomplete, phone unverified - you have a fragile patchwork of endpoints, views, and routing logic that is difficult to confidently refactor, evolve, or maintain.
Task architecture
Task Architecture takes the position that it's the server's job to make the client aware of an action that needs to be taken, rather than the client's job to infer how to behave based on server-side state.
This becomes powerful when the client only needs to "act once." Most of the time, the user does not need to accept new terms and conditions. They don't need to verify their phone. They don't need to refill their KYC info. So why should the client or server burden every request with computing "don't show" the vast majority of the time, when we only need to compute "show" in the small minority of cases that actually call for it?
The shape of the pattern can vary, but there are three core pieces that, when followed consistently, let you scale the number of these concerns - accept TOC, update subscription, submit feedback, verify phone, complete KYC - without any linear burden on remapping existing flows, client-side routing, view-specific endpoints, or polluted resource endpoints.
1. The Task Resource
The first core piece is a uniquely identifiable, completable Task.
- Uniquely identifiable means the client can distinguish clearly between Task types. An Accept_TOC task is not a Submit_Feedback task.
- Completable means the client can either take action itself or communicate to a user a completion criterion for the received Task.
The simplest shape of a Task:
type Task = {
id: number;
type: "Accept_TOC";
completed_at: string | null;
};A more complete implementation might look like:
type Task = {
id: number;
type: "Accept_TOC";
created_at: string;
completed_at: string | null;
// Lifecycle controls
dismissable: boolean;
dismissed_at: string | null;
expires_at: string | null;
// Dispatcher hints
blocking: boolean; // must complete before continuing
priority: "critical" | "high" | "normal" | "low";
category: "compliance" | "billing" | "profile" | "feedback";
// Provenance (useful for debugging and analytics)
source: string; // e.g. "policy_update_2026_05"
// Handler payload
metadata: {
form_method: "POST";
form_endpoint: "/accept/toc";
document_link: "https://example.com/toc";
// ... whatever the handler needs
};
};The two fields worth a closer look are blocking and expires_at.
- blocking is what lets a single Tasks endpoint serve both "you must do this before continuing" (a compliance top-up) and "you should do this when convenient" (rate this feature) - without it, the dispatcher has no way to decide between hard-modal-blocking the UI and surfacing the task as a passive prompt.
- expires_at is what lets the server communicate urgency. It has a real production analogue: Stripe's Connect requirements include a current_deadline field on the same object that lists what's outstanding.
However you implement it, the important constraint is that the Task carries all the information the client needs to complete it. No follow-up call to figure out the context. The Task itself is the contract.
2. The Tasks Endpoint
The second piece is a single endpoint that returns all Tasks relevant to the calling client or user - whether that's a person who needs to accept TOCs or an IoT device that needs to update its firmware.
Don't burden the server to say "don't show" when you only care about "show".
Keeping in mind the principle above, the Tasks endpoint returns only uncompleted tasks. An empty response means there's nothing pending.
GET /tasks
[
{
id: 1,
type: "Accept_TOC",
created_at: "2026-05-05"
},
{
id: 2,
type: "Submit_Feedback",
created_at: "2026-05-04"
}
]A note on what this endpoint is not: if your UI needs to display completed tasks as an activity log or audit trail, build that as a separate feature backed by separate records (created on Task completion). Don't overload the Tasks endpoint with completed work. That re-introduces the same noise problem you're trying to escape.
3. The Task Dispatcher
On the client, you implement a single standard utility - call it a Task Dispatcher - for loading Tasks and routing each one to its handler. Just as any client needs to know an endpoint's request and response shapes, it needs to know how to handle each Task type.
const tasks = await fetch("/tasks");
TaskDispatcher(tasks, {
Accept_TOC: renderAcceptTOCHandler,
Submit_Feedback: renderSubmitFeedbackHandler,
});That's it. Three pieces. The Task type, the endpoint that serves them, and the dispatcher that routes them.
Bringing it together
Let's revisit the pitfalls from earlier and see how Task Architecture handles them.
On Client-Side Inference
Without Tasks, we were either creating new endpoints per state or polluting resource endpoints with one-time fields - making the client do the inference work. With Tasks, the flow becomes:
user submits email → server issues [Submit_Account_Info, Accept_TOC (if applicable)] → client renders dashboard with Task Dispatcher overlaying tasks → user completes tasks → dispatcher clears, dashboard is unblocked
The user lands on the dashboard immediately, and the Task Dispatcher overlays the pending tasks on top of it - typically as a modal or guided flow. Whether the server issues two tasks or twenty, the pattern doesn't change. No new endpoints. No new routing logic.
On Out-of-Flow Obligations
When the TOC needs to be re-accepted by existing users, the server simply issues a new Accept_TOC task to each of them. Clients currently connected via websockets receive the task immediately. Everyone else gets it on their next app load. No new components, no new code paths. The same Accept_TOC handler that ran during sign-up runs again now - because the obligation was never really owned by the sign-up flow in the first place.
The generalization that pays for the architecture
The examples above use narrow, single-purpose Task types. That's fine for illustration, but it understates the payoff. The real economic argument for Task Architecture is that Task types can be abstract enough to subsume entire categories of features.
Consider that both Submit_Account_Info and Accept_TOC are, structurally, "show the user a form, collect their input, submit it somewhere." Both could be expressed as a single, generic Submit_Form Task, whose metadata defines the form schema:
type Task = {
id: number;
type: "Submit_Form";
metadata: {
title: "Accept Updated Terms";
fields: [
{ type: "document_viewer"; src: "https://example.com/toc" },
{ type: "checkbox"; name: "accepted"; required: true }
];
submit: { method: "POST"; endpoint: "/accept/toc" };
};
};Once you have one generic Submit_Form handler on the client, the server can drive questionnaires, quizzes, satisfaction surveys, KYC top-ups, document uploads, and feature acknowledgements without an additional line of client code per feature request. The marginal cost of new product features in this category drops from "engineering ticket" to "configuration change."
That's the real argument for the pattern. It converts a category of work from O(features) to O(1).
When not to use this
Like anything, it's not the right tool for every problem.
- The Tasks contract has to stay in sync. Every new Task type means a new client handler. If you can't ship server and client in coordination, the pattern strains.
- Don't use it for purely informational content. The simplest valid Task is one that requires an acknowledgement from the client - an action the server needs to know was taken. If you just want to show a banner, surface a tip, or push a notification with no completion semantics, use whatever you'd normally use for that.
- It has a real storage cost. You could delete completed tasks if the state they updated is now reflected elsewhere, but in practice you're looking at a database record per task. That's fine when Tasks are infrequent and high-signal (one TOC re-acceptance per policy update). It compounds fast under dynamic use cases - create a Task every time a user does X so they then do Y and Z. Plan for retention, archival, or aggressive cleanup before the table becomes the problem.
- It's overkill for small applications. If you have two flows that will never change, just write the two flows. Patterns are for things that compound.
In summary
Treat it as a discipline rather than an implementation - which is the piece that matters. Keep the channel scoped to completable actions. Resist every temptation to make it carry anything else. The server owns what needs doing. The client owns how to do it. Stop building the third layer where the client is constantly guessing.
Also, one last thought on why patterns like this matter at all.
Conversations about development devolve quickly into religious wars, and language wars are the worst of them. Ruby's famous design goal is developer happiness, and Ruby programmers stay Ruby programmers for exactly that reason. They love the language. They find most others unattractive and unenjoyable to work with.
But after years of building things across languages and frameworks, I've come to think language choice is one of the smaller levers on whether a codebase stays a joy to work in. The bigger lever is architecture - the patterns you commit to and the discipline to apply them consistently. A well-architected C# service ages better than a vibe-coded Rails project.
Patterns like this one may be boring, unsexy, and rarely the subject of a conference talks. But they're where the sustained joy in evolving an application actually lives. Pick yours carefully.
Subscribe to new posts
Get new posts delivered to your inbox.