{"id":"grfsrt4wzvuctki","title":"Backend JSON APIs: Shapes I Design So Frontends Stay Fast","slug":"backend-json-api-design-for-frontends","summary":"Chatty REST and bloated graphs both hurt. I design endpoints for one screen, stable keys, and pagination that does not punish mobile.","imageUrl":"https://briancrabtree.me/images/journal-backend-json-api-design-for-frontends.webp","category":"Backend","date":"2026-02-02T18:00:00.000Z","featured":false,"likes":32,"author":"Brian Crabtree","content":"<h2>One screen, one round trip</h2>\n\n<p>Frontends suffer when the home view needs six endpoints before paint. I compose read models: /api/home returns hero, featured posts, and footer links in one JSON document with explicit cache headers. Writes stay granular; reads can be denormalized.</p>\n\n<p>BFF layers are not shameful. They are honesty about who owns aggregation. The browser should not orchestrate five parallel fetches for above-fold content unless those requests are truly independent and cacheable at CDN edge.</p>\n\n<p>GraphQL persisted queries help CDN cache POST bodies on edge when your provider supports it. Without that, POST GraphQL is harder to cache than GET REST with query params for public reads.</p>\n\n<pre><code>GET /api/posts?fields=slug,title,summary&amp;limit=20\n{\n  \"items\": [{ \"slug\": \"my-post\", \"title\": \"...\", \"summary\": \"...\" }],\n  \"nextCursor\": \"abc\"\n}</code></pre>\n\n<p><figure>\n  <img src=\"/images/journal-inline-json-api-design.webp\" alt=\"JSON API response shape diagram with items array cursor pagination fields\" width=\"1200\" height=\"675\" loading=\"lazy\" />\n  <figcaption>Shape JSON for the UI — not as the database dumped it.</figcaption>\n</figure></p>\n\n<h2>Stable keys and versioning</h2>\n\n<p>Renaming id to postId breaks deployed SPAs silently. I version URLs or use additive fields only. Deprecation windows are communicated in response headers and docs, not surprise 404s on a Friday deploy.</p>\n\n<p>ISO-8601 dates in UTC, explicit null versus missing field semantics, and enums documented in OpenAPI save weeks of Slack arguments. TypeScript types generated from the spec keep mobile and web aligned.</p>\n\n<p>I version DTO fields additively: new optional fields ship first, clients ignore, then mobile app force-upgrade window closes before removing old fields.</p>\n\n<h2>Pagination that mobile can afford</h2>\n\n<p>Offset pagination is simple until page 47 scans your table. Cursor pagination with opaque tokens scales for infinite scroll feeds. I cap page size server-side and ignore client requests for limit=9999.</p>\n\n<p>Include total count only when the product needs it; counts are expensive on large tables. For admin tables, separate count endpoint behind debounced search.</p>\n\n<p>Idempotency keys on POST payments and booking endpoints prevent duplicate charges on retry. Document them in OpenAPI examples, not wiki-only.</p>\n\n<h2>Errors humans and machines read</h2>\n\n<p>Consistent error envelope: code, message, field errors array. HTTP status matches semantics: 422 for validation, 409 for conflicts, 503 with Retry-After when overloaded. Random 500 bodies with HTML stack traces are for logs only.</p>\n\n<p>I log correlation ids in JSON responses so support can trace without exposing internal ids in URLs.</p>\n\n<p>Compression on JSON arrays is excellent; returning 5 MB of denormalized joins is not. Field masks or sparse fieldsets for mobile clients save radio time.</p>\n\n<h2>Caching and conditional requests</h2>\n\n<p>ETag and Last-Modified on read endpoints let CDNs and browsers revalidate cheaply. Short max-age with stale-while-revalidate works for semi-static marketing JSON. Authenticated JSON usually opts out of shared cache; still compress with br and gzip.</p>\n\n<p>Over-fetching GraphQL without persisted queries can negate network wins. I measure resolver depth and N+1 queries before declaring GraphQL the savior.</p>\n\n<p>Health endpoints return dependency status without leaking stack traces. Kubernetes liveness should be cheap GET, not deep DB scan every second.</p>\n\n<h2>Security defaults</h2>\n\n<p>Rate limits per IP and per token, CORS allowlists not wildcards for credentialed calls, and no sensitive fields in GET query strings. Pagination tokens are signed or opaque, not raw SQL offsets exposed.</p>\n\n<p>Public read APIs still need bot protection when scrapers hammer them. I pair CDN rules with sensible defaults in the origin.</p>\n\n<p>Webhook signatures with HMAC and timestamp tolerance protect your origin from replay. Frontends never see those secrets.</p>\n\n<h2>Field experience</h2>\n\n<p>I have wired Spring Boot controllers, Node handlers, and Firebase functions to the same React shells. The winning pattern is predictable JSON, not fashionable transport. Send your OpenAPI spec or wildest response payload; I will redraw it for one round trip and honest cache headers.</p>\n\n<p>I draw sequence diagrams for the worst screen before coding controllers. That diagram becomes the contract product and frontend agree on.</p>\n\n<p>When I audit backend stacks, Field experience is where hours disappear if nobody owns the outcome. I write before-and-after notes, attach PSI or waterfall screenshots, and leave the team a three-item follow-up list. Reproducible wins beat toolchain debates in sprint review. If this section matches your bottleneck, send the URL and whether LCP, INP, CLS, or crawl coverage is the pain point. For a related angle I keep coming back to, see <a href=\"/journal/java-spring-content-apis-headless/\">Java and Spring Content APIs for Headless Frontends</a>.</p>","tags":["api","json","backend"],"views":93}