Blog
Apr 25, 2021 - 19 MIN READ
AJAX → Fetch → GraphQL → tRPC: Choosing Your Data Boundary

AJAX → Fetch → GraphQL → tRPC: Choosing Your Data Boundary

Your data boundary is not “how we call the API.” It’s a coupling contract between teams, runtimes, and failure modes. This post gives you a practical decision framework for REST, GraphQL, and RPC-style boundaries (including tRPC) — with the tradeoffs that show up in production.

Axel Domingues

Axel Domingues

January was the macro view: why modern web architecture looks like it does.

February was the wire contract: HTTP is already a distributed systems API.

March was the runtime reality: the browser is a scheduler with a frame budget, and “random UI bugs” are often deterministic timing problems.

April is where these threads meet:

Your data boundary decides how product requirements become network calls, how teams coordinate change, and how failures show up in the UI.

And it’s one of the most expensive things to “just refactor later.”

What I mean by “data boundary”

Not the transport (it’s usually HTTP).

The boundary is the full contract surface:
  • how clients ask for data
  • how servers shape responses
  • how types and versioning are handled
  • how caching works (or fails)
  • how errors + retries behave
  • how auth + permissions are expressed
  • how the contract evolves without breaking teams

The boundary is where coupling lives

Teams often choose a boundary by vibe:

  • “REST is boring.”
  • “GraphQL is modern.”
  • “tRPC is so clean.”

That’s not architecture.

Architecture is choosing where coupling belongs and what kind of coupling your org can afford.

REST-style

Coupling lives in resource design, endpoint semantics, and compatibility rules.

GraphQL-style

Coupling lives in schema governance and resolver behavior.

RPC / tRPC-style

Coupling lives in shared types, shared runtime assumptions, and release cadence.

“Custom fetch + endpoints”

Coupling lives in your team’s heads… until production forces you to formalize it.

Your job isn’t to pick a “best” boundary.

It’s to pick the boundary whose failure modes your team can operate.


A quick history (because it explains the constraints)

The web didn’t evolve because engineers got bored.

It evolved because product requirements demanded new shapes of coupling.


The boundary decision you’re actually making

When teams ask “REST or GraphQL or tRPC?”, I translate it into five real decisions.

This post is about making those choices explicit.


The myth: “GraphQL solves overfetching / underfetching”

GraphQL can reduce over/under fetching.

But the bigger gain is usually organizational:

GraphQL shines when UI surfaces change faster than backend teams can keep up.

The deeper truth:

Overfetching is usually a symptom of bad boundary granularity, not a missing query language.

  • REST endpoints can be page-shaped or aggregation-shaped too (especially via a BFF).
  • GraphQL can still create waterfalls and slowness if resolvers are slow or the query plan is naive.

Choose GraphQL for change velocity across many clients, not for “bandwidth vibes.”


A practical decision framework

Here’s a framework that avoids ideology and survives architecture reviews.

Step 1 — Classify your client landscape

  • One web client, same repo as backend
  • One web client, separate teams
  • Multiple clients (web + mobile + partners)
  • Public API
  • Unknown future clients (platform risk)

Step 2 — Classify UI volatility

  • Stable screens (internal tools)
  • Moderately volatile (SaaS dashboards)
  • Highly volatile (consumer product, experiments, personalization)

Step 3 — Decide where you can afford coupling

  • Coordinated releases? Tight coupling can be fine.
  • Independent client releases? Your boundary must support independent evolution.

Step 4 — Choose your caching posture

  • Do you need CDN/edge caching to matter?
  • Do you rely on GET semantics for cost + latency?
  • Do you need consistent cache keys and invalidation rules?

Step 5 — Pick the boundary and install guardrails

Whatever you choose, you need:

  • a standard error contract
  • versioning + deprecation rules
  • observability (traces, logs, metrics)
  • performance budgets (response sizes, resolver time, query complexity)
Most teams fail at Step 5.

They choose a tool and skip the governance that makes it safe.


The “default” recommendation (for modern teams)

If you force me to choose a default, it’s not a tool — it’s a posture:

Default posture

Start with typed REST + an explicit schema contract (OpenAPI/JSON Schema) and add a BFF where needed. Graduate to GraphQL only when your client landscape and UI volatility justify the governance cost.

Why this tends to work:

  • it scales to multiple clients
  • it aligns with HTTP semantics and edge caching
  • observability and rate limiting are straightforward
  • you’re not forced into one language runtime
  • it’s easier to operate under incident pressure

But “default” is not “always.” So let’s get concrete.


When REST is the right boundary (and how to make it good)

REST isn’t “old.” REST is discipline.

REST works well when:

  • resources are stable
  • you have clear domain nouns
  • you care about caching and edge behavior
  • you want independent client releases
  • you have multiple client types (including non-TS)

What “good REST” looks like

The real REST tradeoff

REST makes the server choose shapes.

That’s a feature when you want:

  • consistent responses
  • controlled performance
  • caching that works naturally

It’s friction when:

  • UI surfaces change weekly
  • a single screen needs data across many domains
  • UI composition creates endpoint explosion

That’s where GraphQL and/or a BFF often enters.


When GraphQL is the right boundary (and what it costs)

GraphQL shines when:

  • you have many clients with different shape needs
  • UI volatility is high
  • you want a schema contract to coordinate across teams
  • you need composition across domains without endless endpoint proliferation

But you pay for it.

GraphQL’s cost model

Schema governance cost

Deprecation rules, nullability discipline, ownership, review process.

Resolver performance cost

N+1 avoidance, batching, caching, query planning discipline.

Security + abuse cost

Depth/complexity limits, persisted queries, field-level auth.

Observability cost

Tracing must see resolver hotspots, not just “/graphql was called.”

GraphQL is not “hard.”

GraphQL is a governance system disguised as an API protocol.

If you adopt GraphQL without query complexity controls and resolver-level tracing, you’re effectively offering “client-defined workload execution” against your data layer.

That’s a performance and security liability.

A mental model that survives production

  • Treat the schema as a capabilities catalog
  • Treat resolvers as adapters to underlying services
  • Treat batching, caching, and complexity as first-class architecture

GraphQL doesn’t eliminate backend architecture.

It moves it.


When tRPC is the right boundary (and where it breaks)

tRPC is compelling when:

  • your product is full-stack TypeScript
  • you can coordinate client/server releases
  • you want maximum developer velocity
  • you’re building internal-first (no partner API requirements yet)

tRPC becomes painful when:

  • you need public APIs or partners
  • you have mobile clients outside TS
  • you need independent client releases with strict backwards compatibility
  • you need decoupling across teams/services

The honest tRPC contract

tRPC is basically:

“Our boundary is the TypeScript compiler.”

That’s powerful. It’s also a coupling choice.

A common lifecycle:

  • Phase 1: DX bliss, speed, fewer contract mismatches
  • Phase 2: more teams, more clients, release independence matters
  • Phase 3: either
    • you introduce explicit versioned procedures + compatibility rules, or
    • you migrate to contract-first boundaries (OpenAPI/GraphQL)

That’s not failure. That’s maturity.

If you adopt tRPC, decide your exit strategy up front:
  • How will we expose partner APIs later?
  • How will we support mobile later?
  • How will we version procedures later?

The hidden fourth option: BFF (Backend For Frontend)

BFF isn’t a protocol.

It’s an architectural placement: a server layer allowed to be UI-shaped.

A BFF can sit on top of:

  • REST services
  • GraphQL services
  • internal RPC services

Its purpose is to:

  • reduce client complexity
  • reduce waterfalls
  • shape data for a specific UI
  • protect core services from UI churn

BFF is good when

UI is volatile, domain services are stable, and you want independent evolution.

BFF is risky when

It becomes “the real backend” with no ownership, no tests, and no performance budgets.

BFF is often the pragmatic middle ground:

  • stable REST services internally
  • UI-shaped composition in the BFF
  • governance that stays human-sized

A decision matrix you can use in reviews

Use this as a starting point, not a ideology.


Guardrails that matter more than the tool

Most production pain comes from missing guardrails, not the boundary choice.

These are non-negotiable in serious teams:

Standard error contract

One error shape, one tracing story, one retry story.

Versioning + deprecation rules

Additive change by default. Breaking change requires an explicit plan.

Observability

Trace IDs, per-endpoint/per-resolver timings, client-side correlation.

Performance budgets

Response size limits, query depth limits, timeouts aligned to user expectations.

Install these guardrails early and you can survive almost any boundary decision.

Skip them and even the “best” boundary will eventually fail under load and change velocity.


Concrete patterns (because abstractions don’t ship)

Pattern: “latest wins” for user-driven queries

Search boxes and filters are concurrency machines. Make “latest wins” explicit.

// Pseudocode: AbortController + request versioning
let current = 0
let abort: AbortController | null = null

async function load(query: string) {
  current += 1
  const version = current

  abort?.abort()
  abort = new AbortController()

  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: abort.signal })
  const data = await res.json()

  if (version !== current) return // ignore stale responses
  render(data)
}

This applies to REST, GraphQL, and tRPC alike.

Pattern: idempotency for “money buttons”

If the action can create side effects, the boundary must support idempotency.

  • UI disables submit immediately
  • client sends an idempotency key
  • server treats repeats as safe replays

That’s not “backend stuff.”

That’s the boundary doing its job.


Closing: choose a boundary you can govern

Boundaries aren’t DX preferences.

They’re organizational commitments:

  • who owns shape
  • who owns compatibility
  • who owns caching
  • who owns failure semantics
  • who owns evolution

Pick the one you can operate.

April takeaway

A data boundary is a coupling contract. Choose it by org model + change velocity + operability, not by hype.


Resources

MDN — Fetch API

Fetch is the primitive. Understanding it helps regardless of the boundary you choose.

OpenAPI Initiative

Contract-first posture for REST-style APIs: tooling, docs, and compatibility discipline.

GraphQL (Spec + Ecosystem)

A solid baseline for schema evolution concepts and GraphQL mental models.

tRPC — Documentation

End-to-end types with an RPC-style boundary; understand the coupling tradeoff explicitly.


FAQ


What’s Next

Now that we’ve chosen where data contracts live, we need to confront the layer that consumes them:

React.

Not as a UI library — as an architectural tool with cost models.

Axel Domingues - 2026