Filter concepts by levelShowing all levels.

System Design · Section 55

API Gateway and BFF

Level
intermediate
Read
18 min
Concepts
3

An API gateway gives every client — web, mobile, third-party — one shared entry point and one response contract. A Backend-for-Frontend (BFF) instead runs one backend per client experience, each shaped around what that specific client needs, at the cost of an extra deployable per client type. Both perform the same core mechanics on a request — authentication (verified once, at the edge), throttling (capping requests per client), aggregation (fanning out to multiple backend services in parallel and merging their responses), and response shaping (trimming or reshaping the merged result for the client asking) — ideally in that order, since a rejected request should never trigger backend calls it did not need. The section closes on the anti-pattern the roadmap warns against by name: a gateway that accretes retry policies, per-client conditionals, caching rules and one-off transforms one reasonable addition at a time, until it becomes a single codebase every team depends on but no team can test, deploy or reason about safely alone.

System Design overview

What is true here

  1. API gateway: one shared entry point and contract for every client. BFF: one backend per client experience, tailored to what that client needs.
  2. A gateway or BFF authenticates once at the edge, throttles per client, aggregates backend calls in parallel, and shapes the merged response for the client asking — roughly in that order.
  3. Reach for a BFF only when clients genuinely diverge in shape, aggregation or release cadence — otherwise it is extra services with no tailoring to show for them.
  4. A gateway that accretes retry policies, per-client conditionals, caching rules and one-off transforms over time becomes an untestable monolith and a single point of failure for every team's traffic.

What you will be able to do

  • Decide between a shared API gateway and a BFF per client based on how much clients actually diverge
  • Order a gateway/BFF request pipeline so cheap checks (auth, throttling) run before expensive ones (aggregation, shaping)
  • Recognize logic creep in a gateway before it becomes an untestable, tightly-coupled single point of failure

Choosing the shape

A shared entry point for every client, or one backend tailored per client experience — and when each is the better fit.

API Gateway vs Backend-for-Frontend

coreintermediate

An API gateway gives every client — web, mobile, third-party — the same shared entry point and the same response shape. A Backend-for-Frontend (BFF) is a separate backend layer per client type, each one shaped around what that specific client actually needs.

Think of it as

A single API gateway is one restaurant menu handed to every table, dine-in or takeout. A BFF is a menu printed differently per table type: the takeout menu drops the plating photos and adds a packaging fee line, the dine-in menu adds wine pairings — same kitchen behind both, different presentation for a different way of consuming the meal.

text
API gateway:  web ─┐
              mobile ─┼─→ [one gateway, one contract] → services
              partner ┘

BFF:          web ────→ [web-bff]    ─┐
              mobile ──→ [mobile-bff] ─┼─→ services
              partner ─→ [partner-bff]┘

What we're doing: Compare how a mobile app and a web app get product-page data through a shared gateway versus through a BFF per client.

gateway-vs-bff.txttext
Shared API gateway (one contract for both clients):
  GET /api/products/42
    -> returns { title, price, images[], description,
                 relatedProducts[], reviews[] }
  Mobile app receives the full payload, discards
  everything except title, price and one thumbnail.

BFF per client:
  Mobile:  GET mobile-bff.example.com/products/42
             -> { title, price, thumbnailUrl }
  Web:     GET web-bff.example.com/products/42
             -> { title, price, images[], description,
                  relatedProducts[], reviews[] }
  Each BFF calls the same catalog-service underneath,
  but shapes the response for its one client.
4
The shared gateway sends the same rich payload to a mobile client that only renders three fields — wasted bytes on every request, worse on a mobile network.
9
The mobile BFF calls the same downstream catalog-service as the web BFF, so the two are not duplicating business logic, only shaping the response differently.

Why this works: The underlying data and services are identical in both cases — the only thing that changes is whether one shared contract or several tailored ones sits in front of them, and that choice is what determines how much unused data a lean client has to receive.

Adding client-specific fields to a shared gateway response instead of introducing a BFF

Wrong

text
// shared gateway endpoint, growing conditionals
// for each client that asks for something different
if (req.headers['x-client'] === 'mobile-ios') {
  response.thumbnailUrl = ...;
} else if (req.headers['x-client'] === 'android') {
  response.thumbnailSmallUrl = ...;
}
// web, partner-api clients follow the same pattern

Better

text
// each client's BFF shapes its own response;
// the gateway/shared contract stays generic
// mobile-bff:
response = { title, price, thumbnailUrl };
// web-bff:
response = { title, price, images, description };

What you see: The shared gateway's response-building code accumulates a growing set of per-client conditionals, a change for one client risks breaking the response shape another client depends on, and nobody can tell from the endpoint alone which fields any given client actually uses.

Why: A single shared contract branching on client type re-creates per-client shaping without the isolation a BFF would have given it — every client's special case now lives in the same file, coupled to every other client's special case.

One shared contract vs one backend per client

API Gateway

  • +One entry point, one response shape, for every client
  • +Owned by a central platform team
  • +Simple to run, but the contract must satisfy every client at once

Backend-for-Frontend

  • One backend per client experience (web, iOS, Android)
  • Owned by the team that owns the matching client
  • More services to run, but each shape fits its one client exactly
  • API Gateway
    • One entry point, one response shape, for every client
    • Owned by a central platform team
    • Simple to run, but the contract must satisfy every client at once
  • Backend-for-Frontend
    • One backend per client experience (web, iOS, Android)
    • Owned by the team that owns the matching client
    • More services to run, but each shape fits its one client exactly

Choosing between a shared API gateway and one BFF per client

Choosing between a shared API gateway and one BFF per client
SituationBetter fit
One client type, or all clients need near-identical dataShared API gateway
Mobile needs a lean payload, web needs a rich oneBFF per client
Small team, limited operational budgetShared API gateway — fewer services to run
Each client team ships and iterates independentlyBFF per client — no shared-contract negotiation
Third-party or public API consumersShared API gateway — a stable, general contract

Together

text
Product page data, requested by two clients:

Mobile app wants:      { title, price, thumbnailUrl }
Web app wants:         { title, price, images[], description,
                          relatedProducts[], reviews[] }

Shared gateway: one endpoint returns the web-sized
payload to both -- mobile pays for data it never
renders.

BFF: mobile-bff returns the lean shape, web-bff
returns the rich shape -- each client gets exactly
what it asked for.

Remember: An API gateway is one shared entry point and contract for every client; a BFF is one backend per client experience, shaped around what that one client actually needs. Reach for a BFF only when clients genuinely diverge — otherwise it is extra services with nothing to show for them.

See also: api gateway responsibilities · client server topology

Advertisement

What it does to a request

Authentication, throttling, aggregation and response shaping — the four mechanics, and the section's closing warning about letting them accumulate unchecked.

Aggregation, authentication, throttling and response shaping

coreintermediate

Aggregation fans a single client request out to several backend services and combines the results into one response. Authentication verifies identity once, at the edge, instead of in every service. Throttling caps how many requests a client can make. Response shaping trims or reshapes a payload for the client that asked for it.

Think of it as

Think of a travel agent booking a multi-city trip. The agent calls the airline, the hotel and the car-rental desk separately (aggregation), checks your ID once at the start instead of at every desk (authentication), only takes a fixed number of bookings per hour so the phone lines are not overwhelmed (throttling), and hands you one itinerary formatted for how you actually travel — a one-page summary for a quick trip, a detailed day-by-day plan for a two-week vacation (response shaping).

text
handle(request):
  identity = authenticate(request.token)          # once
  if not withinLimit(identity, window): return 429 # throttle
  results = parallel(                              # aggregate
    call(serviceA), call(serviceB), call(serviceC))
  return shape(results, request.clientType)         # shape

What we're doing: Trace one home-screen request through authentication, throttling, parallel aggregation and per-client response shaping.

aggregation-request-trace.txttext
GET mobile-bff.example.com/home
Authorization: Bearer <token>

1. authenticate(token) -> user_id=482 (checked once)
2. throttle: user 482 has made 12 of 100 allowed
   requests this minute -- under limit, proceed
3. aggregate, issued in parallel:
   - profile-service.get(482)         (80ms)
   - feed-service.recent(482, n=10)   (120ms)
   - notifications-service.unread(482) (40ms)
   Total wait = 120ms (the slowest call), not
   80+120+40=240ms, because calls run in parallel.
4. shape for "mobile" client type:
   { name, avatarUrl,
     feed: [10 items, title+thumbnail only],
     unreadCount }
   -- drops full bios, full article bodies, and
   read-notification history mobile never renders.
11
Parallel calls mean the client waits for the slowest backend call, not the sum of all three — a sequential version here would take 240ms instead of 120ms.
15
Shaping happens after aggregation, once all three raw responses exist, and is where a BFF earns its keep over a generic shared response.

Why this works: Each mechanic solves a distinct problem at a distinct point in the request: authentication before anything else runs, throttling before backend calls are spent, aggregation to gather the raw data, and shaping only at the very end once the full picture exists to trim from.

Issuing aggregated backend calls sequentially instead of in parallel

Wrong

text
profile = call(profileService)   # waits 80ms
feed = call(feedService)         # then waits 120ms
notifications = call(notifService) # then waits 40ms
# total: 240ms before the client gets anything

Better

text
profile, feed, notifications = parallel(
  call(profileService),
  call(feedService),
  call(notifService),
)
# total: ~120ms -- bounded by the slowest call

What you see: The aggregated endpoint is measurably slower than any single backend service it calls, and its latency grows linearly with every new service added to the aggregation instead of staying roughly flat.

Why: Independent backend calls with no data dependency between them do not need to wait on each other. Running them sequentially adds their latencies; running them in parallel is bounded by whichever one is slowest.

One request: authenticate, throttle, fan out, shape, fan in

Client

one request

BFF

auth, throttle, aggregate, shape

profile-service

feed-service

notifications-service

  • Client — one request
    • leads to BFF
  • BFF — auth, throttle, aggregate, shape
    • leads to profile-service
    • leads to feed-service
    • leads to notifications-service
  • profile-service
  • feed-service
  • notifications-service

The four mechanics and where each one actually runs

The four mechanics and where each one actually runs
MechanicWhat it doesFailure mode if skipped
AggregationFans out to N services, merges N responses into 1Client makes N round trips itself, paying full latency N times
AuthenticationVerifies identity once at the edgeEvery service re-implements and re-drifts its own auth check
ThrottlingCaps requests per client per windowOne noisy client can exhaust capacity meant for everyone
Response shapingTrims/reshapes payload per clientEvery client receives the same bloated, one-size-fits-all body

Together

text
GET mobile-bff.example.com/home  (one client call)

1. Authenticate: verify the bearer token once.
2. Throttle: check this user is under 100 req/min.
3. Aggregate: call profile-service, feed-service and
   notifications-service in parallel.
4. Shape: keep only { name, avatarUrl } from profile,
   the first 10 feed items, and unread counts only
   from notifications -- drop everything else.

Client receives one small, mobile-shaped JSON body.

Remember: Order matters: authenticate and throttle first — they are cheap and can reject outright — then aggregate (in parallel, when calls do not depend on each other), then shape the combined result for the specific client asking.

See also: api gateway responsibilities · gateway vs bff

The gateway that became an untestable monolith

coreintermediate

A gateway or BFF starts as a thin routing layer, but every team finds it convenient to add "just one more thing" — a retry policy, a per-client conditional, a cached response, a feature flag. None of these alone looks like a monolith. Together, over enough time, they turn the one service every request must pass through into a single codebase no team can change without risking every other team's traffic.

Think of it as

A single hallway connects every department in a building. One team asks to bolt a small shelf onto the wall; another adds a sign; another reroutes a cable along the ceiling. Each addition is reasonable alone. After two years, the hallway is so cluttered that nobody can repaint one wall without first mapping every shelf, sign and cable that depends on exactly where things currently sit — and every department still has to walk through it to get anywhere.

text
Healthy gateway growth:     routes -> more routes
                             (still just routing)

Monolith drift:              routes -> routes + retries
                                     + conditionals
                                     + caching rules
                                     + feature flags
                                     + one-off transforms
                             (routing, and everything else)

What we're doing: Trace how one gateway accumulated logic over 18 months until a single deploy broke every client at once.

gateway-logic-creep-timeline.txttext
Month 1:  Gateway routes /api/* to 5 services. Clean.
Month 3:  Mobile team adds a retry policy for a flaky
          payments call, directly in the gateway.
Month 6:  Web team adds response caching for a slow
          catalog endpoint, 10-minute TTL, gateway-side.
Month 9:  A partner integration needs a different
          payload shape -- a one-off transform is added
          as "just for this one partner, temporarily."
Month 12: An A/B test needs a feature flag check;
          added to the gateway so no service redeploy
          is needed.
Month 15: iOS legacy clients need a compatibility
          shim; another conditional branches on
          client version.
Month 18: A change to the payments retry policy,
          made to fix a mobile-specific timeout,
          also changes retry behavior for web and
          partner traffic that shared the same code
          path -- nobody had mapped that the three
          were coupled. All three break in production
          simultaneously.
4
Each individual addition (months 3, 6, 9, 12, 15) was a reasonable, narrow fix — none looks like the start of a monolith on its own.
20
By month 18, the gateway's internal logic has enough hidden coupling that a fix for one team's problem silently changes behavior for two unrelated teams.

Why this works: No single commit in this timeline looks reckless — the risk is cumulative, invisible at each individual step, and only becomes visible once enough unrelated logic shares the same codebase and nobody owns the total picture.

Adding a one-off, undocumented retry policy directly into shared gateway code

Wrong

text
// gateway route handler, "just for payments,
// just because it's flaky right now"
if (route === '/api/payments') {
  return retryWithBackoff(callPaymentsService, {
    attempts: 5, baseDelay: 200,
  });
}
// six months later, three more routes have their
// own slightly different inline retry logic too

Better

text
// retry policy lives in the payments service's
// own client, owned and tested by that team;
// gateway stays a router with no per-route
// retry logic of its own
gateway.route('/api/payments', paymentsService);
// paymentsService's client wraps its own calls
// with a retry policy it owns and can change
// without touching the gateway

What you see: Six months after the first "temporary" retry policy, four different routes each have their own inline retry logic, no two configured the same way, and nobody can say which one is correct without reading all four.

Why: A retry policy is about how to call one specific downstream service reliably — that decision belongs with the team that owns and understands that service's failure modes, not embedded in a shared router every other team also depends on.

Every team's traffic funnels through one increasingly overloaded gateway

Web team

Mobile team

Partner team

Gateway

routing + retries + conditionals + caching + flags

Backend services

every team still depends on the gateway deploying cleanly

  • Web team
    • leads to Gateway
  • Mobile team
    • leads to Gateway
  • Partner team
    • leads to Gateway
  • Gateway — routing + retries + conditionals + caching + flags
    • leads to Backend services
  • Backend services — every team still depends on the gateway deploying cleanly

Individually reasonable additions that accumulate into an untestable monolith

Individually reasonable additions that accumulate into an untestable monolith
AdditionWhy it seemed fine aloneWhat it costs once dozens exist
A retry policy for one flaky downstream callFixes a real, narrow problem quicklyDozens of undocumented retry policies, each slightly different, none owned
A per-client conditional (`if clientType === "ios-legacy"`)Unblocks one client team without a new deploy elsewhereA growing if/else tree nobody can trace end to end
Response caching for one expensive callImproves latency for a real hotspotCache invalidation rules scattered across routes with no shared policy
A feature flag check for one experimentLets one team ship a trial without touching servicesFlags outlive their experiments and nobody is sure which ones still matter
A one-off transformation for one client's payloadSmall, contained, "just for now"Dozens of one-off transforms, each a hidden dependency a service does not know exists

Together

text
Gateway codebase, 18 months in:
  - 40 routes
  - 12 different retry policies (no shared config)
  - 30+ client-type conditionals across 15 files
  - caching logic on 8 routes, 8 different TTL rules
  - 6 stale feature flags nobody remembers the purpose of

A new engineer joining the platform team cannot
predict what a single route will actually do without
reading through all of the above -- and no test suite
exercises every combination.

Remember: A gateway earns its keep by staying a thin, well-tested router plus a small fixed set of cross-cutting concerns. Every retry policy, per-client conditional, caching rule or one-off transform added "just this once" is a small loan against that; enough of them and the gateway becomes a single point of failure and a bottleneck every team depends on but no team can safely change alone.

See also: gateway business logic boundary · api gateway responsibilities · gateway vs bff

Advertisement