Mobile architecture
Do you need a BFF for your mobile app?
A BFF is usually sold as a way to turn many calls per screen into one. The harder half is organizational: someone has to own and run it, and every app version you shipped pins its contract. When a backend for frontend pays off, who owns it, how to change it safely, and when not to build one.
15 min read
Most answers to this question count calls. A screen makes ten requests, a backend for frontend turns them into one, and the screen gets faster. That part is true, and it is the smaller half of the decision. A BFF is a service someone has to own, run and get paged for, and its response shape becomes a contract with every app version you have shipped. Those two facts decide whether a BFF pays off more often than the request count does.
I have run 500+ technical interviews from the hiring side over 15 years in mobile. In system design rounds, the BFF often appears as a box between the phone and the services, drawn in a few seconds. The answer is made in the follow-up questions: who owns that box, who is on call for it, and what happens to it while version 4.2 of the app is still in use a year from now.
My book Controlled Change defines a BFF in its glossary as a server boundary shaped for one client or experience, and ends the entry with the warning that matters most: "It becomes a coupling hotspot when its ownership of product semantics is ambiguous."
- The short answer: build a BFF when screens depend on several services, when clients need different shapes, or when backend churn keeps reaching the app. Do not build one when one client talks to one service.
- The real decision: who owns the contract, who owns the runtime, and where business rules live (not in the BFF).
- The part most guides skip: old app versions pin the BFF's response shape, so changing it is as binding as changing a public API.
What a BFF is, and what it is not
A backend for frontend is a server-side layer built for one client experience: the iOS and Android apps, say, or the mobile checkout. It calls the services that own the data, composes their answers, trims what the screen does not use, and translates internal names into the language of the app. It owns no business truth. Prices, inventory, entitlements and payments stay with the services that are authoritative for them.
- Not an API gateway. A gateway is shared infrastructure in front of every client: routing, TLS, authentication checks, rate limits. Some gateways can aggregate calls too. The difference is the contract: a gateway applies the same rules to everyone, a BFF serves one experience a shape made for it.
- Not GraphQL, and not its opposite. GraphQL lets a client select fields from a shared schema, which addresses over-fetching and can fold several calls into one query. The schema is still shared across clients, with the governance of a shared contract. A GraphQL server can be the mobile BFF, and a BFF can sit behind a graph. The decision below applies to both.
- Not a domain service. Controlled Change draws this line in its chapter on domain boundaries. A checkout screen can compose data from several contexts through an experience-specific projection or a BFF, and that is not a reason to merge the domains: "It is a reason to define a composition boundary."
Composition boundary is the useful name. The BFF decides how data from several owners is put together for one experience. It does not decide what that data means.
When a BFF pays off
A BFF earns its running cost when at least one of these is true. The strongest cases have two or three:
- A screen depends on several services, in sequence. On a cellular connection every round trip pays latency, and calls that wait on each other pay it in series. Each extra call is also another place for the screen to fail halfway. Moving the fan-out into the data center replaces several slow hops from the phone with one. The book's performance chapter asks exactly this of a checkout journey: which network calls are sequential, and which can prefetch?
- Clients need different shapes from one API. When the mobile and web teams negotiate every change to one response, the API serves neither well and changes slowly for both. A contract per experience ends the negotiation.
- Backend services change faster than apps update. A downstream team renames a field or splits a service. Behind a BFF, an adapter changes and the app contract does not. Without one, every installed app is coupled to the internal shape of every service it calls.
- The app needs translation. Services speak their own domain language and status codes. The BFF maps them to the states the app shows, so a backend rename does not become a client release.
- You want server-side composition without a UI runtime. Ordering sections, hiding a module in one region, adding an optional field: a BFF can do these behind a fixed native UI. Controlled Change lists the cases where full server-driven UI does not fit, then adds: "A simple backend for frontend (BFF) response plus native UI is often enough."
Only the first reason is about speed. The other four are about change and ownership, which is why the next two questions are not about latency at all.
When not to build one
The rule I would put first: if one client talks to one service, do not build a BFF. The service API is already the client's contract. A layer in between adds a deploy, a network hop, an on-call surface and a second place to change every field, and it absorbs nothing.
| Situation | Build a BFF? | Why |
|---|---|---|
| One app, one backend service | No | Nothing to aggregate or absorb; improve the service API instead |
| One slow endpoint | No | Fix or cache the endpoint; a layer in front of it adds a hop |
| Small team that owns app and API together | Usually no | The contract already moves at the app's speed; add an endpoint shaped for the screen |
| Nobody can be on call for it | Not yet | A BFF is production infrastructure; an unowned one is a failure mode, not a shortcut |
| Several services per screen, called in sequence | Often yes | One round trip from the phone; the fan-out stays in the data center |
| Mobile and web negotiating one response | Yes, one per experience | A contract per client ends the negotiation |
| "We might add more clients later" | No | Build it when the second client or the second service arrives |
The third row deserves a note. A screen-shaped endpoint on an existing service is a lighter form of the same idea. It becomes a BFF question only when it starts composing several services or needs a deploy cadence of its own.
Who owns it, and who is on call
A BFF is two things with two natural owners. Its contract is product work that changes with the screens. Its runtime is a production service with scaling, patching, alerts and an error budget. Controlled Change splits ownership into dimensions instead of one team name: product decisions, domain policy, code stewardship, runtime responsibility, data responsibility, delivery, platform responsibility and deprecation. Written out for a BFF, the dimensions that cause trouble are these:
| Dimension | The question for the BFF | The answer that fails |
|---|---|---|
| Product decisions | Who decides what a screen receives, and in what shape? | Whoever has time that sprint |
| Domain policy | Which service owns each rule the BFF exposes? | The BFF itself |
| Runtime | Who is paged when latency doubles at night? | "Shared" |
| Delivery | Who deploys it, and how is a bad deploy rolled back? | Tied to an app release |
| Deprecation | Who removes a response shape once old apps stop using it? | Nobody, by default |
Two arrangements work. The mobile team owns both contract and runtime, which needs engineers who can run a service and carry a pager. Or a platform team owns the runtime, deployment template and resilience defaults, while the mobile team owns the contract code inside that frame. The arrangement that fails is a common default: the mobile team asks for changes, a backend team implements them from a queue, and nobody owns the journey end to end. The book has a one-line test for it: "If five teams each own a layer but no one owns the journey, the system's architecture is fragmented."
Whichever you pick, write it down with the reason, so it can be revisited when the team changes. A record that names the owner, the rejected option and the condition to revisit fits in a technical design doc.
Old app versions pin the BFF contract
This is the part most BFF guides skip. A BFF absorbs churn from the services behind it. It cannot absorb churn in its own contract, because its consumers are app binaries. Every version you have shipped decodes the response shape it was compiled against, and some of those versions will still be running long after the team has forgotten that shape.
So a BFF does not make the mobile contract easy to change. It moves the hard contract to a place you control. A change to a BFF response is as binding as a change to a public API, and the rules are the ones Controlled Change gives for any request and response schema in its chapter on version skew:
- add optional fields with safe defaults, and keep the meaning of existing fields stable; in the book's words, "Compatibility includes meaning."
- never repurpose an old enum case, and represent unknown values explicitly on the client;
- introduce new operations, or new section kinds, when semantics change materially;
- let the server expose capabilities rather than infer behavior from version numbers;
- keep golden request and response fixtures for every supported client, and run them in the BFF's CI before each deploy;
- label contract errors in production by client version and endpoint, without capturing sensitive payloads.
The client half matters as much. A newer BFF will eventually send something an older app has never seen. The app has to survive it without crashing and without pretending:
// One screen, one call. Every section says whether the BFF could fill it.
struct HomeScreen: Decodable, Sendable {
let contractVersion: Int
let sections: [HomeSection]
}
struct TripSummary: Decodable, Sendable { let id: String; let title: String }
struct Recommendation: Decodable, Sendable { let id: String; let title: String }
enum HomeSection: Decodable, Sendable {
case upcomingTrips([TripSummary])
case recommendations([Recommendation])
case unavailable(kind: String) // the BFF could not fill it in time
case unknown(kind: String) // a kind this app version has never seen
private enum CodingKeys: String, CodingKey { case kind, status, items }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let kind = try c.decode(String.self, forKey: .kind)
if try c.decodeIfPresent(String.self, forKey: .status) == "unavailable" {
self = .unavailable(kind: kind)
return
}
switch kind {
case "upcomingTrips":
self = .upcomingTrips(try c.decode([TripSummary].self, forKey: .items))
case "recommendations":
self = .recommendations(try c.decode([Recommendation].self, forKey: .items))
default:
self = .unknown(kind: kind)
}
}
}
enum SectionRendering: Equatable { case content, retryNotice, skip }
func rendering(for section: HomeSection) -> SectionRendering {
switch section {
case .upcomingTrips, .recommendations: return .content
case .unavailable: return .retryNotice // an empty list here would be a lie
case .unknown: return .skip // newer BFF, older app: skip, never crash
}
}Two choices in that code are policy, not style. An unknown section is skipped, so a new BFF feature never crashes an old app. An unavailable section renders a notice instead of an empty list, because an empty list tells the user there is nothing there when the truth is that the BFF could not find out. Ship this tolerance in the first app version that talks to the BFF; it protects only the versions that contain it.
Inside the BFF, version branches accumulate. A line that sends the old shape to apps older than 5.3 is reasonable once. Twenty of them with no removal date are a maintenance burden nobody planned. Give each branch an owner and a removal condition tied to your support window; how long that window should be, and who may end it, is in how long to support old mobile app versions. When a change cannot be additive, use the book's expand and contract sequence: expand the server, expand the clients, activate for capable clients, observe old traffic, stop producing the old form, and remove server support only after evidence and the support window allow it.
Aggregation without hiding failure
Aggregation fixes chattiness and changes how the screen fails. Three calls from the phone fail one at a time, and a careful client can show what arrived. One call to a BFF fails as a unit, unless the BFF is designed to report partial results. Decide per section before writing code:
- Required or optional. If the screen is useless without a section, its failure fails the response. If not, the section degrades to an explicit unavailable state.
- A time budget per downstream. A slow recommendation service should cost its own section, not the whole screen.
- One retry layer. If the phone retries the whole call and the BFF also retries every downstream, a slow service receives multiplied load at the worst moment. Choose one place to retry, with backoff.
- Same freshness, same response. Do not fold a live price into a payload cached for an hour. Split responses by how fresh their data must be and by who may see it; a cached response keyed without the account is a privacy incident.
- Writes are not aggregation. A BFF can forward a command with its idempotency key. It should not coordinate a transaction across services. In its checkout case, Controlled Change tells the client: "Do not ask the client to orchestrate independent payment and inventory calls". The booking service coordinates them. Moving that coordination into the BFF moves the partial-commit problem to another server without solving it.
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
data class Profile(val name: String)
data class Trip(val id: String, val title: String)
data class Rec(val id: String, val title: String)
sealed interface Section {
val kind: String
data class Ready<T>(override val kind: String, val items: List<T>) : Section
data class Unavailable(override val kind: String) : Section
}
data class HomeResponse(val contractVersion: Int, val greetingName: String, val sections: List<Section>)
interface Downstream {
suspend fun profile(userId: String): Profile
suspend fun upcomingTrips(userId: String): List<Trip>
suspend fun recommendations(userId: String): List<Rec>
}
class RequiredDownstreamFailed(val downstream: String) : Exception("required downstream failed: $downstream")
// Optional: a slow or failing call degrades its own section, never the screen.
suspend fun <T> optional(kind: String, budgetMs: Long, call: suspend () -> List<T>): Section {
val items = try {
withTimeoutOrNull(budgetMs) { call() }
} catch (e: CancellationException) {
throw e // the phone went away: stop the whole fan-out
} catch (e: Exception) {
null // count it per downstream; do not retry here
}
return if (items != null) Section.Ready(kind, items) else Section.Unavailable(kind)
}
// No retries inside the fan-out: the phone already retries the whole call.
// One retry layer, not two, or a slow downstream gets multiplied load.
suspend fun home(userId: String, api: Downstream): HomeResponse = coroutineScope {
val profile = async {
withTimeoutOrNull(800) { api.profile(userId) } ?: throw RequiredDownstreamFailed("profile")
}
val trips = async { optional("upcomingTrips", 800) { api.upcomingTrips(userId) } }
val recs = async { optional("recommendations", 300) { api.recommendations(userId) } }
HomeResponse(
contractVersion = 3,
greetingName = profile.await().name,
sections = listOf(trips.await(), recs.await()),
)
}The rules are in the comments: a required downstream fails the call, an optional one degrades only its own section, a client that has gone away cancels the whole fan-out, and nothing retries inside it. Each unavailable section is what the Swift code above renders as a notice.
How a BFF fails in production
The failures are predictable, which makes them cheap to look for in a design review:
- The second monolith. Validation goes in because it is convenient, then a business rule because a downstream team is slow, then a cache, a queue and a scheduled job. Rules belong to the services that own them; the BFF composes and translates.
- One BFF for every client. A BFF shared by mobile, web and partners is the shared API again, with an extra hop.
- Ownership nobody can name. The glossary warning in practice: the BFF holds product meaning and no one is sure who decides it.
- Retry amplification. Retries at every layer turn one slow downstream into an outage.
- Partial failure shown as empty data. The screen says there are no upcoming trips when the trips service timed out.
- A meaning change the tests missed. A field keeps its type and changes its meaning, and old apps show wrong data instead of crashing. Only fixtures for old clients catch it.
- Authorization that lives only in the BFF. The BFF can reject early, but the service that owns the data still decides what the user may do; otherwise any other path to that service skips the check.
- Version branches that never die. Each one still runs, still needs tests, and still slows the next change.
When a failure does reach users, the BFF is also a control point: a server-side switch there reaches every app version that calls it, including versions that never shipped a client-side flag. The limits of that kind of control are covered in mobile app kill switch design.
A BFF is not a sync engine
If parts of the app must work offline, the BFF is not where that problem gets solved. Screen-shaped responses are read projections for a moment when the phone is online. Offline-first data needs a different contract: operations with stable identities, cursors, conflict rules and an owner of the truth for each kind of data.
Many apps need both, side by side: a sync channel for the data the user edits offline, and a BFF for screens that compose online data. Do not route sync through screen endpoints, and do not ask the sync engine to compose screens. The sync side of that decision is in how to choose a mobile data sync solution.
A one-page BFF decision
Every field is something a reviewer can check. If two of them are empty, the BFF is not ready to be built.
| Field | What to write |
|---|---|
| Experience | Which client or journey the BFF serves, and which it does not |
| Evidence | Calls per screen today, which of them are sequential, and field latency for the key journeys |
| Lighter option | Why a screen-shaped endpoint on an existing service is not enough |
| Contract owner | The team that decides the response shapes |
| Runtime owner | The team paged for it, with the alert and the error budget |
| Domain rules | Confirmation that none live in the BFF, and where each one does live |
| Failure policy | Required and optional sections, time budgets, the one retry layer |
| Compatibility | Additive rules, fixtures per supported app version, errors labeled by version and endpoint |
| Version branches | Each with an owner and a removal condition tied to the support window |
| Revisit when | A second client arrives, a team changes, or the BFF starts holding rules |
What I listen for when a candidate draws the BFF box
| Topic | Mid-level | Senior | Staff |
|---|---|---|---|
| Why | "To reduce the number of calls." | Fewer round trips and a payload shaped for the screen. | Round trips, plus a contract per experience that absorbs backend churn, and the rule for when not to build one. |
| Ownership | Not mentioned. | "The mobile team owns it." | Contract owner and runtime owner named separately, with on-call and a written reason. |
| Old versions | "We version the API." | Additive changes, a v2 for breaking ones. | Fixtures per supported app version, capabilities over version checks, branches with removal conditions tied to the support window. |
| Failure | "Return an error." | Timeouts on downstream calls. | Required and optional sections, an explicit unavailable state, one retry layer, no domain rules in the BFF. |
The staff column does not add boxes. It is the same box with an owner, a failure policy and a plan for every app version that will call it.
Questions engineers ask about a BFF for mobile apps
What is a backend for frontend in a mobile app?
A backend for frontend, or BFF, is a server-side layer built for one client experience, such as the iOS and Android apps. It calls the services that own the data, composes and trims their responses into what a screen needs, and translates internal names into the app's language. It does not own business rules; those stay in the services that are authoritative for them.
What is the difference between a BFF and an API gateway?
An API gateway is shared infrastructure in front of every client: routing, TLS, authentication checks and rate limits under the same rules for everyone. A BFF belongs to one experience and serves it a contract shaped for its screens. Some gateways can aggregate calls as well; the difference is who owns the contract and who it is shaped for.
Who should own the mobile BFF?
Split the question in two. The contract, meaning the response shapes, should be owned by the team that builds the screens, because it changes with them. The runtime needs a team that can run a production service and carry a pager. That can be the mobile team, or a platform team that provides the runtime and defaults while the mobile team owns the contract code. Write the choice and the reason down.
Do you need a BFF if you use GraphQL?
Often not a separate one. A GraphQL server can play the BFF role, because clients select the fields they need and several calls can become one query. The schema is still shared across clients, and fields queried by shipped app versions cannot be removed without breaking those versions. The ownership and compatibility questions in this article apply to a graph as much as to a hand-built BFF.
How do you version a BFF when old app versions are still in use?
Mostly by not breaking it. Make additive changes, keep the meaning of existing fields stable, let clients skip unknown values, and prefer capability negotiation to inferring behavior from version numbers. Keep golden fixtures for every supported app version in the BFF's CI. When a change cannot be additive, use expand and contract, and remove the old shape only after traffic and the support window allow it.
