REST, OpenAPI, and TypeSpec: how I’d design an API in 2026 so caching doesn’t fight me later

Casey Holt

Casey Holt

September 18, 2026

REST, OpenAPI, and TypeSpec: how I'd design an API in 2026 so caching doesn't fight me later

I have shipped three APIs that were “RESTful” on the slide and uncacheable in production. Each time the autopsy looked the same: we designed for the OpenAPI happy path — paths, schemas, a generated TypeScript client — and treated HTTP caching as something Cloudflare would sprinkle on later.

Cloudflare cannot sprinkle. If every GET carries a unique Authorization dance, if you encoded freshness in a POST body, if your list endpoints change shape when a query param is missing, the edge will either miss forever or serve the wrong tenant. I have done both. I prefer not to do them again.

This is how I would design an API in 2026 so caching is a property of the contract, not a weekend project after traffic shows up. OpenAPI is still the interchange. TypeSpec is how I keep that interchange from rotting. REST is the constraint system I actually want, not a synonym for JSON over HTTPS.

Cache first, then the path names

I start with a boring question: which resources are reusable across callers, and which are private? Public product catalog, yes. A user’s invoice, no. A feature-flagged price, maybe, and “maybe” is where people invent Cache-Control: no-store for the whole API because they got scared.

Reusable resources get:

  • A stable GET URL. Not a POST to /search with a JSON body that happens to be a filter.
  • Explicit freshness. max-age for data I can be a minute wrong about. s-maxage when I want the CDN to hold it longer than the browser.
  • A validator. ETag from a content hash or a row version. Last-Modified if the domain already has a clock I trust.
  • A Vary list I can explain to a junior. If I cannot explain it, I am about to cache the wrong variant.

Private resources still use GET when they are reads. They just do not go on a shared cache. private, no-store is a decision. It is not the default I reach for because thinking is hard.

The design sin I see most in 2026 is GraphQL-shaped REST: one POST /graphql equivalent, or a single POST /query that takes a JSON document, because the frontend team wanted one round trip. You can cache that only with a custom key that includes the body, and now you have invented GET with extra steps and none of the tooling. If you need a BFF aggregation, make it a BFF. Do not make the public API a dumpster for POST reads.

What OpenAPI still gets right

OpenAPI 3.1 is the document I can hand to a gateway, a contract test, a documentation site, and a code generator. I still want that document. I do not want to maintain it by hand in a YAML file that drifts from the Spring controllers by Thursday.

The caching-relevant parts people skip in the spec:

  • Response headers as first-class. If ETag, Cache-Control, and Vary are not in the spec, they will not be in the generated clients, and they will not be in the Pact tests. They will be folklore in a nginx snippet.
  • Header parameters that affect representation. Accept-Language, Accept-Currency, a custom X-View. If it changes the body, it belongs in Vary, and it belongs in the spec so someone does not add a fourth one in a hotfix.
  • Error shapes that do not poison caches. A 500 with a long max-age is a classic. I write the 4xx/5xx cache policy in the same response object as the 200.
  • Pagination as a resource, not as an accident. /catalog?page=2&pageSize=50 is cacheable if page size is an enum. /catalog?cursor=eyJ... is cacheable if the cursor is stable. A cursor that embeds “now” is a cache miss machine.

I also put security schemes in the spec like I mean them. A Bearer token on every route makes shared caching a non-starter unless I have a separate public surface. I have started splitting the public catalog API from the authenticated account API as two OpenAPI documents, two gateways, two cache policies. One spec with “some routes are public” becomes a single Cloudflare Cache Rule that someone sets to bypass because a private route leaked once.

A wall of API diagrams and cache header notes on a whiteboard

Where OpenAPI starts to fight me

OpenAPI is a description of HTTP. It is not a great language for saying “this model is the same as that model except the price field is omitted for anonymous users.” You end up copying schemas or inventing oneOf forests. The cache behavior of those two representations is different. The spec makes them look like a documentation problem. They are a variant problem.

Composition is the other pain. I want a shared Money type, a shared ProblemDetails error, a shared pagination envelope. In raw OpenAPI I can $ref my way into a maze. In a big repo the maze wins. People paste. Pasted schemas drift. Drifted schemas become “why is the CDN serving a body the iOS app cannot parse.”

Code-first annotations — Springdoc, NestJS decorators, FastAPI inference — stay honest only while someone looks at the rendered spec in CI. I run spectral (or a similar linter) on the generated file and I fail the build if Cache-Control is missing on a GET that we marked cacheable. If you do not fail the build, you do not have a policy. You have a wiki.

What TypeSpec adds that I actually use

TypeSpec is the first IDL in this space that did not make me feel like I was writing a worse Java. I write the API as types and operations. I emit OpenAPI for the rest of the world. I emit clients when I want them. The source of truth is the TypeSpec, not the YAML, not the controller annotations.

The win for caching is that I can model variants and headers once. A decorator on an operation that says “this is a public GET, emit these cache headers, vary on Accept-Language” becomes a team convention instead of a comment in a PR. I have a small library of those decorators. They are not official gospel. They are how I keep six services from inventing six policies.

TypeSpec also makes the “two surfaces” split cheaper. I can share models between catalog.tsp and account.tsp without copy-paste, and emit two OpenAPI documents. The catalog document is what the CDN config is allowed to know about. The account document never gets a shared-cache rule.

What TypeSpec does not do: it will not save a bad resource model. If I still POST my reads, I have a beautifully typed uncacheable API. If I still put tenant id only in a JWT and then try to cache GET /items on a shared edge, I have a beautifully typed security incident. The IDL is a lever. It is not a conscience.

I would not introduce TypeSpec on a three-endpoint internal tool. I would introduce it when we have more than one consumer language, or when the OpenAPI YAML has started to grow comments like “DO NOT EDIT, except we always edit it.” That comment is the smell.

A laptop showing API spec and response headers during a late work session

The HTTP details I refuse to leave implicit

Authorization and the shared cache. If the GET is public, I do not send a Bearer token “just in case the client has one.” Browsers and intermediaries get weird. If the GET is private, I do not put it on a shared cache key that ignores the Authorization header. Cloudflare’s cache key tools will let you do something clever. Clever is how you serve my invoices to someone else. I would rather miss.

HEAD exists. I use it for cheap validators when the client already has a body and wants to know if it should refetch. OpenAPI should declare it. Generated clients should not treat it as a surprise.

304 is a feature. If I emit ETags, I implement If-None-Match. If I do not implement it, the ETag is decoration and I have trained the client to ignore validators.

POST is for non-idempotent work, or for the rare case where the query cannot fit a URL and I accept that it will not be cached. I do not use POST to hide a GET because someone once said query strings are ugly. Query strings are how caches key.

Deletes and mutations bust something explicit. I prefer a versioned collection ETag or a short max-age over a tangle of purge keys I will get wrong. If I do use purge keys — Fastly, Cloudflare — they are named in the spec as response headers so the BFF can send them. Hidden purge logic in a worker is how we cached a sold-out SKU for twenty minutes during a drop. I remember the Slack thread. I do not want another.

A shape I would use on a catalog API

Say we sell parts. Public catalog, private carts.

GET /v1/parts/{sku} — Cache-Control: public, max-age=60, s-maxage=300, ETag from the part’s content revision, Vary: Accept-Language. TypeSpec model Part shared with the search hit.

GET /v1/parts?q=&family=&page= — same cache policy, page size fixed at 50 in the spec so we do not have a combinatorial explosion. I reject unknown query params instead of ignoring them. Ignored params are cache-key landmines.

GET /v1/carts/{id} — authenticated, private, no-store, no CDN. Different document. Different gateway.

POST /v1/carts/{id}/lines — 201, Location, no cache. After a successful write I do not invent a clever purge of the catalog. The catalog did not change.

That last sentence is where junior designs go wrong. They couple everything to everything so they can “stay consistent.” Consistency is a domain rule. Caching is an HTTP rule. If adding a line to a cart should change a part’s “in stock” field, that field was never a public cacheable fact, or I need a stock resource with its own, shorter freshness.

What I would not do in 2026

I would not generate OpenAPI from code and treat the annotations as optional. I would not write TypeSpec and then hand-edit the emitted YAML “just this once.” I would not put cache policy in a CDN dashboard that only one SRE can open. I would not design a single RPC-looking POST because the mobile team wanted to batch twenty reads. I would give them a BFF or a batch GET with a documented, cacheable URL shape — for example a comma-separated id list with a hard cap.

I would not chase gRPC-Web as a caching strategy. Fine for internal service-to-service. Useless if the point of the public API is that any HTTP intermediary can help.

And I would not wait for “scale” to think about this. Caching fights you later because the URL shape and the auth shape are expensive to change. The traffic is the easy part. The contract is the fossil.

The order I work in now

Write the TypeSpec with cache and vary as part of the operation, not as an afterthought. Emit OpenAPI. Lint the OpenAPI in CI. Generate the server stubs or the client, not both from different sources. Configure the CDN from the public document, preferably as code — Terraform, a wrangler config, a checked-in Cache Rule — so a spec change can fail a pipeline if the edge still thinks everything is no-store.

REST is the constraint. OpenAPI is the passport. TypeSpec is the workshop where I keep those two from lying to each other. Caching is not a product feature I add when I am done. It is the test that the API is actually an API, and not a pile of JSON tunnels that happen to use GET when someone remembered.

More articles for you