Skip to content

    Frontend System Design

    Consuming REST, GraphQL and tRPC from a Client

    Learn how a front end consumes REST, GraphQL and tRPC: what each does about over-fetching, caching, types and errors, and how to choose one per screen.

    By Published 19 min read

    • frontend-system-design
    • rest
    • graphql
    • trpc
    • api-integration
    Share

    Flagship lessonintermediateFrontend System Design

    Before this: API Integration Basics: Fetching, Headers and Failure (coming soon)

    What you'll learn: By the end of this lesson you will be able to explain over-fetching and under-fetching and how each style (REST, GraphQL, tRPC) changes them, say what the browser and a client cache can reuse in each style and why, describe where each style's types come from and what they do not guarantee at run time, read the three error models (HTTP status, GraphQL data plus errors, tRPC error codes) and turn them into one error type the UI can act on, and choose a style for a screen with the trade-offs written down.

    A restaurant can serve you in three ways. A set menu arrives as fixed plates, and some of it goes back untouched. An à la carte menu lets you order each dish, but dinner for four needs six orders to six waiters. Or you hand over a form with a tick box for every ingredient, and the kitchen assembles one plate. Each moves work and risk to a different place.

    REST, GraphQL and tRPC are three ways for a client to order data. This lesson is about the consuming side: what the browser code sees, what it can cache, what its compiler knows, and how it finds out something went wrong. How a back end designs and serves these styles is in API Styles: REST, gRPC, GraphQL and WebSockets and API design. Here each style gets only a working definition.

    The running example is a product page for a small shop: name, price and stock, a review summary, a short list of related products, and whether the signed-in user has the product on a wishlist. Five pieces of data, owned by different parts of the back end. The first version makes seven requests and the page flickers as they arrive.

    The core idea

    Four questions, answered differently by each style. The answers are the lesson.

    1. Shape. Who decides which fields come back: the server (a fixed representation), the client (a query), or the code (a function's return type)?
    2. Round trips. How many requests does one screen need, and do later ones wait for earlier ones?
    3. Reuse. What can the browser's HTTP cache, a CDN or a client-side cache hand back without asking again, and what is the cache key?
    4. Failure. Where does the outcome live (status, body, or both), and can a response be half good?

    A fifth question crosses all four: what does the compiler know? Types make a front end faster to change, but they are only as true as whatever produced them.

    Working definitions

    REST-style HTTP GraphQL tRPC
    Unit A resource at a URL, used with HTTP methods A query, mutation or subscription, usually to one endpoint A procedure (typed function) called by path name
    Who picks the fields The server, unless the API offers selection The client, in the query The procedure's declared return shape
    Types come from A separate contract (for example OpenAPI), or none The server's schema The server's TypeScript code, shared with import type
    Typical transport HTTP, GET for reads HTTP, POST for queries and mutations (GET allowed for queries) HTTP, GET for queries, POST for mutations

    REST-style HTTP. RFC 9110, the HTTP semantics standard, defines GET, HEAD, OPTIONS and TRACE as safe (essentially read-only) and PUT, DELETE and the safe methods as idempotent (repeating the request has the intended effect of sending it once). POST is neither. Clients, caches and retry logic can rely on those meanings.

    GraphQL. The specification (October 2021 edition, the one used here) defines a query as "a read-only fetch", a mutation as "a write followed by a fetch" and a subscription as "a long-lived request that fetches data in response to source events". A typed schema says what can be asked.

    tRPC. Its documentation calls it "one implementation of RPC", designed for TypeScript monorepos, with "its own flavor, but is RPC at its heart". RPC means you call a function and get a response, where REST means you call a URL and get a response.

    Over-fetching and under-fetching

    These are informal terms, not defined by a standard, so here are the definitions this lesson uses. Over-fetching is a response that carries fields the screen never shows (bytes, parse time, sometimes data the user should not receive). Under-fetching is a request that does not carry enough, so the client makes another, and often the second cannot start until the first answers. That chain is a waterfall.

    On the product page, the related-products request needs the product's category, and the category arrives in the product response. The second request waits for the first.

    Figure 1. A waterfall in a resource-style API, and one query

    sequenceDiagram accTitle: Waterfall of dependent requests versus one GraphQL query accDescr: In the first half the browser asks the product endpoint, waits, then asks the related products endpoint using the category from the first reply. In the second half one GraphQL query asks for the product and its related items together and the server resolves them behind one request. participant B as Browser participant A as Product API participant R as Related API Note over B,R: Resource-style: the second URL needs data from the first reply B->>A: GET /products/p1 A-->>B: product (includes categoryId) B->>R: GET /categories/c9/related R-->>B: related products Note over B,R: GraphQL: one request, the server resolves the parts B->>A: POST /graphql (product and related) A->>R: resolve related (server side) R-->>A: related products A-->>B: one response

    How to read this: in the top half the second request waits on the first, so the browser pays two round trips. In the bottom half the dependency still exists, but it is resolved on the server side of the network, where the hop is usually short.

    What each style does about it:

    • REST: the server can offer richer representations, or the client starts independent requests together. Dependent requests stay dependent. A server component that builds one response per screen is the BFF pattern, the subject of the next lesson, Backend for Frontend (BFF).
    • GraphQL: the client names the fields it wants, nested ones included, so one query can span several resources. The spec lets the server run a query's fields in any order, since order must not change the result, so independent parts can be resolved in parallel.
    • tRPC: a procedure returns its declared shape, so over-fetching is a design choice of the procedure. Under-fetching is attacked in the transport: httpBatchLink combines calls made in the same tick of the event loop into one HTTP request. Batching cannot remove a dependency: a call that needs another call's answer cannot share its batch.

    Illustration 1 (minimal example): one product, three styles

    Endpoints and names are illustrative. The REST and GraphQL snippets use the built-in fetch. The tRPC snippet follows the shape on tRPC's client setup page, needs the @trpc/client package and a server that exports its router type, and was not run here.

    // REST: the server decides the representation.
    const res = await fetch("/api/products/p1");
    if (!res.ok) throw new Error(`Response status: ${res.status}`);
    const product = await res.json(); // may carry many more fields than the card shows
    // GraphQL: the client names the fields it wants.
    const res = await fetch("/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        query: "query Card($id: ID!) { product(id: $id) { name price } }",
        variables: { id: "p1" },
      }),
    });
    const body = await res.json(); // { data, errors? }: read both, see below
    // tRPC: call a typed function; the path is the procedure's name.
    import { createTRPCClient, httpBatchLink } from "@trpc/client";
    import type { AppRouter } from "../server/router"; // a type: erased at build time
    
    const trpc = createTRPCClient<AppRouter>({
      links: [httpBatchLink({ url: "/trpc" })],
    });
    const product = await trpc.product.byId.query({ id: "p1" });

    Two details deserve a second read. MDN says fetch() rejects for a network error, but "if the server responds with an error like 404, then fetch() fulfills with a Response", so the status must be checked. And import type is erased at compile time; tRPC's setup page warns that importing the router value, not its type, pulls server code into the client.

    Caching differences

    Two kinds of cache matter. The browser's HTTP cache and shared caches (a CDN or proxy) reuse a stored response for a matching request, keyed mainly by method and URL. A client-side cache in your JavaScript (a data-fetching library's store) is keyed by whatever key your code or the library chooses. HTTP Caching and Caching Across the Stack cover the layers in depth.

    Figure 2. Layers that can reuse a response, and what each one keys on

    Cache layers between a component and the origin, with the key each layer usesComponent to Client cache (read); Client cache to HTTP cache (miss: fetch); HTTP cache to CDN or proxy (miss); CDN or proxy to Origin API (miss)IN YOUR JAVASCRIPTSTANDARD HTTP CACHINGComponent: asks for dataComponentasks for dataClient cache: key: query key or idClient cachekey: query key oridHTTP cache: key: method, URLHTTP cachekey: method, URLCDN or proxy: key: URL, VaryCDN or proxykey: URL, VaryOrigin API: REST, GraphQL, tRPCOrigin APIREST, GraphQL,tRPCreadmiss: fetchmissmiss

    How to read this: a request falls through the layers until one has a usable copy. The client cache is yours to key. The two standard layers can only key on what is in the HTTP request, so the request style decides how much they help.

    REST: the URL is the cache key. RFC 9110 says the response to a GET is cacheable, so GET /api/products/p1 can be reused by the browser cache and a CDN within its freshness rules. Different resources have different URLs, so they expire and are invalidated separately.

    GraphQL: one endpoint, usually POST. The Serving over HTTP page says a server must handle POST for queries and mutations and may accept GET for queries only. RFC 9110 says responses to POST are only cacheable with explicit freshness information, and a POST request cannot be answered from a cached POST response. So the standard layers have little to work with by default. Three consequences:

    • GET for queries can make HTTP and edge caching available, but the query text is in the URL, and the same page warns that long query strings can exceed URL limits in browsers and CDNs.
    • The page suggests persisted documents (also called automatic persisted queries or trusted documents): the server stores documents, and the client sends a short identifier instead of the text.
    • The caching page on graphql.org notes there is no URL-like primitive giving a GraphQL object a globally unique identifier, which a client cache needs. It recommends a reserved id field, or deriving one on the client from __typename plus a type-unique id. A cache keyed by object identity can update every place the object appears, which is why a mutation should return the fields it changed.

    tRPC: GET for queries, input in the URL. The HTTP RPC page says queries use GET with the input JSON-stringified in an input query parameter, and mutations use POST with the input in the body. A query's URL therefore identifies the procedure and its input, which is what HTTP caches key on. Batching changes that: batched calls join the procedure names with commas in the path, so the URL describes the whole combination. As a consequence, two pages that batch different calls produce different URLs even for a shared procedure. Cacheability also depends on response headers, and the tRPC pages read for this lesson do not describe cache headers, so design and test HTTP caching of tRPC queries rather than assuming it. For the client cache, tRPC's TanStack Query integration builds query keys from the procedure path and uses prefix matching, so one invalidateQueries call can target a whole router. That is a property of the library's keying scheme, not of the transport.

    Types: where they come from and what they promise

    • REST: the contract lives outside the code, often an OpenAPI document that TypeScript types are generated from (a later lesson, OpenAPI to TypeScript contract types, is planned and not yet published). Without a contract, client types are hand-written guesses.
    • GraphQL: the server publishes a typed schema, and queries can be checked against it before they ship. The shape of a result follows from the query: a client that asked for name price is told about those two fields.
    • tRPC: the client imports AppRouter as a type that "holds the shape of your entire API", and the client is "a typed JavaScript Proxy". Renaming a server field becomes a client compile error because both are built from the same TypeScript, which is why the docs say tRPC is designed for monorepos.

    None of these guarantees that the bytes that arrive at run time match the types. tRPC's types are inferred at compile time; input is checked at run time only where a procedure declares a validator, and then, the validators page says, a failing input stops the procedure and the call fails with BAD_REQUEST. The tRPC pages read for this lesson describe no client-side check of the response against the type. A deployed bundle is also a snapshot: a user can keep an old one open while the server deploys a newer router, and a compile-time check cannot protect code compiled earlier. A planned lesson, API contract evolution for clients, covers that.

    Error models: three places for the outcome to hide

    A UI needs one question answered every time: did it work, and if not, what can the user do?

    Resource-style HTTP: the status carries the outcome. RFC 9110 defines the codes a client reacts to: 400 (cannot or will not process the request), 401 (lacks valid authentication credentials), 403 (understood but refused), 404 (no current representation found), 409 (conflict with the resource's current state), 422 (content understood but unprocessable), 503 (currently unable to handle the request). The body can add detail in a standard shape: RFC 9457 defines problem details, a JSON object with media type application/problem+json and members such as type, title, status, detail and instance. It says consumers should not parse detail, and must ignore extension members they do not recognise.

    GraphQL: the status is not enough. A response is a map with data, errors and extensions. A request error (syntax or validation) happens before execution and data is absent. A field error happens during execution: the field is treated as null, an entry is added to errors, and the rest of the response can still be returned, so data and errors can both be present. That is a partial response. Each error has a message, and a path when it can be tied to a field. If a field declared non-null would be null, the error propagates to its parent, and if every field up the path is non-null, data itself becomes null.

    Over HTTP, the Serving over HTTP page says that when data is non-null the server should respond 2xx "even if the response includes errors, since HTTP does not yet have a status code representing 'partial success'". It also says clients should send Accept: application/graphql-response+json, and that responses should use that media type, though application/json is allowed for legacy clients. The page calls the GraphQL-over-HTTP specification a draft, so servers differ. Read both the status and the body.

    tRPC: a code in an error object. The error-handling page says errors come back under an error property with a message, a numeric JSON-RPC code, and data holding a tRPC code, an httpStatus and a path. Each code maps to one HTTP status, for example BAD_REQUEST to 400, UNAUTHORIZED to 401, NOT_FOUND to 404, CONFLICT to 409, TOO_MANY_REQUESTS to 429 and SERVICE_UNAVAILABLE to 503. An error that is not a TRPCError is wrapped as INTERNAL_SERVER_ERROR and, unless the server hides it, keeps its original message, so the server's errorFormatter decides what the browser sees. For batches with mixed outcomes, the HTTP RPC page says the server returns 207 Multi-Status.

    Figure 3. Reading an outcome in each style

    flowchart TD accTitle: How a client decides whether a call worked in each style accDescr: First check whether fetch rejected, which means a network failure. Then read the response by style. For resource-style HTTP, check the status. For GraphQL, check whether data is present and whether errors exist. For tRPC, check for an error object and map its code. All paths end in one UI outcome. A["Call started"] --> B{"fetch() rejected?"} B -->|"yes"| N["Network failure:
    offline, DNS, TLS, CORS"] B -->|"no"| C{"Which style?"} C -->|"REST"| R{"response.ok?"} R -->|"yes"| OK["Success"] R -->|"no"| RE["Read status and
    problem details"] C -->|"GraphQL"| G{"data null?"} G -->|"yes"| GE["Failure: read errors"] G -->|"no"| G2{"errors present?"} G2 -->|"no"| OK G2 -->|"yes"| PART["Partial success:
    show data, flag the paths"] C -->|"tRPC"| T{"error object?"} T -->|"no"| OK T -->|"yes"| TE["Map tRPC code
    to user action"]

    How to read this: a network failure is the one case where fetch rejects, in all three styles. After that, the place to look differs, and GraphQL is the only style whose core model has a normal "success with warnings" outcome.

    Illustration 3 (failure case): the failure that looks like success

    A GraphQL response for the product page when the reviews service timed out:

    {
      "data": { "product": { "name": "Kettle", "price": 24 }, "reviewSummary": null },
      "errors": [{ "message": "Reviews service timed out", "path": ["reviewSummary"] }]
    }

    The HTTP status is 200. A client that checks only response.ok shows an empty reviews area with no explanation. A client that treats any errors as total failure discards a page that is mostly fine. The fix is a third outcome in the UI. This normaliser turns two styles into one Outcome type; it was run in Node 26 with the inputs described below it.

    export type Outcome<T> =
      | { ok: true; data: T; warnings: string[] }
      | { ok: false; error: { source: string; message: string; retryable: boolean } };
    
    export async function restOutcome<T>(res: Response): Promise<Outcome<T>> {
      if (res.ok) return { ok: true, data: (await res.json()) as T, warnings: [] };
      const isProblem = res.headers.get("content-type")?.includes("application/problem+json");
      const problem = isProblem ? await res.json() : null;
      return {
        ok: false,
        error: { source: "rest", message: problem?.title ?? `HTTP ${res.status}`, retryable: res.status === 503 },
      };
    }
    
    type GqlError = { message: string; path?: (string | number)[] };
    type GqlBody<T> = { data?: T | null; errors?: GqlError[] };
    
    export function graphqlOutcome<T>(body: GqlBody<T>): Outcome<T> {
      const errors = body.errors ?? [];
      if (body.data == null) {
        return { ok: false, error: { source: "graphql", message: errors[0]?.message ?? "no data", retryable: false } };
      }
      return { ok: true, data: body.data, warnings: errors.map((e) => `${e.path?.join(".") ?? "request"}: ${e.message}`) };
    }

    Given a 404 with an application/problem+json body (title "Product not found"), restOutcome returns {"ok":false,"error":{"source":"rest","message":"Product not found","retryable":false}}. Given a bare 503, it returns the message HTTP 503 with retryable true. Given the response above, graphqlOutcome returns ok: true with the warning reviewSummary: Reviews service timed out. Given data: null, it returns ok: false.

    The retryable rule is a placeholder, not a recommendation. RFC 9110 says a client should not automatically retry a request with a non-idempotent method; real retry rules belong to the planned lessons on retries and backoff.

    Choosing a style for a screen

    The choice is per conversation, not per company: plain cacheable GET endpoints for a public catalogue, GraphQL for a complex dashboard, tRPC for an internal tool built in the same repository.

    Figure 4. A first-pass decision per screen

    flowchart TD accTitle: First-pass choice of API style for one screen accDescr: Start with whether the data is public and shared. If yes, resource-style GET with HTTP caching. If not, ask whether client and server are one TypeScript codebase owned by one team, which suggests tRPC. If not, ask whether several clients need different shapes of connected data, which suggests GraphQL. Otherwise resource-style HTTP, possibly behind a BFF. A["One screen's data"] --> B{"Public, shared,
    cacheable by URL?"} B -->|"yes"| R1["Resource-style GET
    with HTTP caching"] B -->|"no"| C{"Client and server in one
    TypeScript codebase, one team?"} C -->|"yes"| T["tRPC is a candidate"] C -->|"no"| D{"Several clients need different
    shapes of connected data?"} D -->|"yes"| G["GraphQL is a candidate"] D -->|"no"| R2["Resource-style HTTP,
    maybe behind a BFF"]

    How to read this: it is a first pass, not a rule. The second question is about team and codebase shape rather than technology, because tRPC's value comes from sharing types inside one codebase.

    Concern REST-style HTTP GraphQL tRPC
    Over-fetching Server decides Client selects fields Procedure decides
    Waterfalls Common across separate URLs One query can span several Batching helps independent calls
    HTTP and CDN caching Natural for GET Needs GET plus short identifiers, or app-level caching Queries use GET; batch URLs vary; design and test
    Client cache key URL or hand-made key Object identity (__typename plus id) Procedure path plus input
    Types Separate contract or none Schema Inferred from server TypeScript
    Run-time guarantee None by default Query checked against schema on the server Input validated if a validator is declared
    Partial success Not in the model In the model (data plus errors) A batch can mix outcomes (207)

    The other side of this topic

    How a back end designs these APIs, how it limits expensive GraphQL queries, and how it chooses a style per conversation is in the back-end lesson API Styles: REST, gRPC, GraphQL and WebSockets. A consumer should read it for one reason: every limit the server puts on a query or a batch becomes an error your UI must handle.

    In practice: a product page that makes seven requests

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    This section is Illustration 2, the realistic example. A small online shop has the product page described above: product details, price and stock, a review summary, related products and a wishlist flag for signed-in users. It issues seven requests (illustrative): three for product data, one for reviews, two for related items (find the category, then list), one for the wishlist. On a slow connection the page paints in stages and the related list appears last. Three front-end and two back-end engineers own it, and the product manager wants a calmer page.

    Starting architecture

    Figure 5. Seven requests, four services

    Starting architecture: the product page calls several endpoints directlyProduct page to Catalogue API (3 calls); Product page to Reviews API (1 call); Product page to Related API (2 calls, in series); Product page to Wishlist API (1 call)Product page: 7 requests (illustrative)Product page7 requests(illustrative)Catalogue API: details, price, stockCatalogue APIdetails, price,stockReviews API: summaryReviews APIsummaryRelated API: needs category firstRelated APIneeds categoryfirstWishlist API: per-userWishlist APIper-user3 calls1 call2 calls, in series1 call

    How to read this: four back-end services, one page, seven requests. The dashed arrow is the waterfall: the second related call waits for the category.

    Options considered

    Option What it fixes What it costs Risk
    One screen-shaped endpoint, rest stays REST Fewer requests, one place to degrade gracefully A new server component with an owner Becomes a catch-all (see the BFF lesson)
    A GraphQL layer The page asks for exactly its fields in one request Schema, resolvers, caching and query-cost work HTTP caching lost unless GET and persisted documents are added
    tRPC for the page's data Typed calls, batching of independent calls Client and server must share a TypeScript codebase The back-end services are not in that codebase

    The decision

    Field Decision record
    Context Seven requests with one waterfall; four services owned by two teams; catalogue data is public and cacheable, the wishlist is per user.
    Decision Keep GET for public catalogue data so CDN and browser caches apply. Add one screen-shaped read returning the five areas with a per-area status, so a slow reviews service shows a placeholder, not an empty page. The front end reads it through the normaliser with three outcomes: success, partial, failure.
    Consequences, good Fewer round trips; one front-end error type; public data stays cacheable.
    Consequences, bad The aggregating endpoint has an owner and an on-call rota; its response is a new contract that must stay compatible with older cached bundles.
    Revisit when More clients need different shapes of the same data (favouring GraphQL), or front end and aggregation code end up in one TypeScript repository (making tRPC worth comparing).

    The decision chooses per surface, not one style for the whole company.

    Rollout and verification

    1. Contract first. Write the response type and a sample with one area failing, and build the UI against the sample before the endpoint exists.
    2. Ship behind a flag to a small share of sessions and compare request counts and field latency.
    3. Test the three outcomes. Force the reviews area to fail and confirm the product still shows and the user can add to the basket.
    4. Check the caches. Public catalogue responses still carry freshness headers and are served from the browser cache on a second visit, and no per-user response is stored by a shared cache.

    The 2 a.m. failure

    The pager fires: client error reports on the product page jump, but the HTTP success rate on the aggregating endpoint is flat. Customers report a white page after a release.

    The cause is on the client. The release renamed reviewSummary to reviews. Some users still run the previous front-end bundle in a long-lived tab, and the new response has no reviewSummary. The old code reads data.reviewSummary.count without a null check and throws. No error boundary wraps the reviews area, so the whole page unmounts. The server returned 200 throughout, which is why the success-rate alert stayed green.

    First three checks: (1) compare the bundle version in failing sessions with the current one; (2) read the body of a failing request, not just its status; (3) find the field named in the stack trace in the response type. Mitigation: serve the old field name alongside the new one. Follow-up: keep contract changes additive for at least as long as the longest-lived bundle, wrap each independent area in an error boundary, and alert on client error rate as well as server status.

    Architect's notes

    • Decide per surface, not per company. Public cacheable data and a per-user dashboard want different tools.
    • Put the three outcomes (success, partial, failure) in one front-end type on day one. It is cheap then and expensive later.
    • Treat a type as a claim, not a proof. Where data crosses a trust or deployment boundary (a third-party API, an old bundle against a new server), validate at the edge of the client.
    • Measure before adding a layer. Read the waterfall first; the cheapest fix is often starting independent requests together.

    Common mistakes

    • Reading only response.ok. It misses GraphQL field errors that arrive with a 2xx status.
    • Treating any GraphQL errors as total failure. It discards partial data the user could have used.
    • Assuming fetch rejects on 404 or 500. Only network-level failures reject.
    • Believing tRPC types protect the browser at run time. They are compile-time inferences, and an old bundle can run against a newer server.
    • Parsing the detail text of a problem response. The RFC says not to; use a stable code or extension member.

    Key takeaways

    • The four questions are shape, round trips, reuse and failure; each style answers them differently.
    • Over-fetching wastes bytes; under-fetching causes more requests, and dependent ones form a waterfall.
    • With resource-style GET, the URL is the cache key. GraphQL's single endpoint and POST limit standard HTTP caching, so it leans on persisted documents, GET queries and identity-keyed client caches.
    • tRPC queries use GET with the input in the URL and batch independent calls into one request; batch URLs vary with the set of calls.
    • Types come from a contract (REST), a schema (GraphQL) or shared TypeScript (tRPC), and none proves what arrives at run time.
    • fetch() rejects only for network-level failures; HTTP error statuses resolve.
    • GraphQL can succeed with errors, so the UI needs a partial-success outcome; a tRPC batch can mix outcomes too.
    • Normalise every style into one error and outcome type before it reaches components.
    • Choose per surface, and write down why.

    Practice exercises

    Exercise 1: Starter (30 min). Take a screen you know and list every request in order. Mark which depend on earlier answers. Redraw the sequence with independent requests started together and note what you could not parallelise.

    Exercise 2: Intermediate (2 hours). Implement restOutcome and graphqlOutcome in a small project, add a third function for a tRPC-shaped body ({ error: { message, data: { code, httpStatus } } }), and test a table of inputs: success, 404 with problem details, 503, GraphQL partial, GraphQL null data.

    Exercise 3: Advanced (half day). Pick one response type in a project and decide where it is validated at run time. Add a validator at the client edge for one response, write a test where the server returns a changed shape, and decide what the UI does for each failure.

    In this course:

    Elsewhere on the site:

    Next in this course: Backend for Frontend (BFF), the server component that gives one screen one request.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments