Most frontend code treats asynchronous work like a single shape. You kick off a Promise, wait for it to resolve, dump the result into local state, and let the framework reconcile the diff. This pattern is seductive because it works everywhere: a REST call, a form submission, a WebSocket message. They all land in the same useEffect or event handler, all get pipelined through setState, all look like identical async plumbing. That uniformity is a trap. In a real application, not all async work is the same. Pretending it is turns your UI components into accidental data architects, patched together with useEffect hooks and crossed fingers.
Async operations actually fall into three different species. Each one has a different relationship with time, caching, and ownership. Learning to tell them apart is what keeps a frontend fast, correct, and sane.
Queries: Facts with an Address
A query is not just a fetch. It is a request for an identifiable fact. You are asking for /user/123, not "some user data." That distinction matters because identity is what makes caching possible. If two components on the same screen both need the same user record, they should share one answer. When each component keeps its own local copy in useState, you fragment your truth. The avatar in the header and the name in the sidebar drift apart because they were fetched at different times, or one failed while the other succeeded.
Think of a query as a resource, not an action. It has a cache key, a freshness policy, and a lifecycle that outlives any single component. A well-built query layer understands that reading /projects?page=2 is different from reading /projects?page=3. Each URL and parameter set forms an address, and data at that address can be stale, fresh, or missing. The UI should not own this bookkeeping. It should ask the data layer for user:123 and receive a snapshot. Whether that snapshot came from the server two seconds ago or from a cache two milliseconds ago is none of the component's business.
The practical fallout is immediate. When you treat every read as an imperative fetch inside a component, you lose deduplication. You lose background refresh. You lose the ability to show cached data instantly while validating it in the background. A query deserves a home outside your UI tree.
Mutations: Changing the World
If queries ask about the world, mutations change it. The actual network request—the POST, PUT, or DELETE—is usually the easy part. The hard part is everything that happens after the server says "OK."
Suppose a user updates their display name. The mutation itself is a single request. But the blast radius is everywhere. The profile page holds the old name. The navigation bar shows the old name. The comment history might reference it. If your mutation code does nothing but toggle a local isLoading flag and then update one piece of state, your application is now lying to itself. Some corners of the UI pretend the change happened. Others have no idea anything changed at all.
A mutation must declare its impact on the data graph. It should tell the system which queries are now invalid, which cached keys need to be refetched, and which relationships have shifted. This is fundamentally different from a query. A query is read-only and shareable. A mutation is write-focused and destructive to existing caches. Flattening them into the same abstraction means developers end up manually calling refetch() inside random components, or worse, peppering useEffect hooks across the tree to "sync" local state back into alignment with the server.
The ownership model is also different. Queries are usually owned by the cache. A mutation is owned by the user action that triggered it. It has a pending state, an error state, and potentially an optimistic value that needs to be rolled
