Backend for Frontend (BFF) pattern
Trace BFF adoption and naming, then follow MAKONEA source code and UML through its web proxy, BFF cache, and content service.
· Last updated: 2026-10-28 · 10 min read
When did BFF emerge, and who shaped it?
2013 · SoundCloud engineering team: Introduced dedicated APIs for experiences that had shared a public API across web, mobile, and outside integrations. SoundCloud dates this adoption in its 2021 retrospective.1
Early development at SoundCloud · Nick Fisher: The web tech lead named the approach BFF. Phil Calçado recorded this in his 2015 account.2
September 18, 2015 · Phil Calçado: Published the development history and explained why screen responses moved from client assembly into the backend.2
November 18, 2015 · Sam Newman: Described the pattern and team responsibilities using examples from REA and SoundCloud.3
A website can start with a server API built around its own screens. Mobile apps and outside integrations change that arrangement: they may use the same data while needing different responses. A detailed product page, a quick mobile interaction, and an outside developer now depend on changes to the same API.
The SoundCloud case published by Thoughtworks in December 2015 explains the everyday friction behind the change. A new app endpoint had to be argued for, prioritized, and implemented by another team responsible for the shared API. Owning a BFF let the frontend team decide how to fetch data and shape its response.4
Overview
A Backend for Frontend, or BFF, is a server layer that offers an API suited to a particular frontend. It receives screen requests, calls the services it needs, and builds responses. The team building the interface also maintains this API. Combining data from several services into one response is one responsibility it can take on.5
In MAKONEA, apps/bff-edge handles API requests for the web app. For a public post query, it validates the locale and options, checks the cache, and calls content-svc. The content service filters published posts and prepares the list data. The example below follows this route.
A small example: opening the MAKONEA blog list
A blog card needs a title, description, and reading time. The browser sends its desired count and content type to a public list API rather than reading database rows. This example follows an actual screen request through the server boundaries and the code that owns each step.
GET /api/public/blog/ko?type=post&limit=10A browser call reaches the same-origin Next.js API proxy, which forwards it to the BFF. The BFF maps it to the content service path /v1/public/posts/ko. Server rendering can call the configured BFF address directly; the UML below follows the browser API path.67
MAKONEA components in UML
Dashed dependency arrows point from the component using another component toward that dependency. The web app, BFF, and content service are separate components. publicCacheStore is a memory cache inside the BFF process, while content-svc queries the database.89
The request sequence in UML
This sequence follows a list read on a cache MISS. After the content service replies, the BFF applies its cache policy and returns the response through the Next.js proxy. On a cache HIT, the BFF returns the stored response and skips the content-service read.109
Actual code 1: the web app builds query options
These are actual function excerpts from revision 0b705e8. fetchPublicBlog puts the locale, content type, page size, and other options into the URL. withBffBase selects the address for the calling environment; withServerFetchTimeout adds a deadline to server requests.7
export async function fetchPublicBlog(
locale: string,
type?: 'post' | 'project' | 'casual' | 'memo' | 'lab',
init?: FetchInit,
opts: { limit?: number; offset?: number; category?: string; preview?: boolean } = {},
) {
const params = new URLSearchParams();
if (type) params.set('type', type);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
if (opts.category) params.set('category', opts.category);
if (opts.preview) params.set('preview', '1');
const qs = params.toString() ? `?${params.toString()}` : '';
return fetch(withBffBase(`/api/public/blog/${locale}${qs}`), withServerFetchTimeout(withPublicContentTag(init)));
}Actual code 2: the BFF manages cache and service calls
Before this excerpt, the route validates inputs with localeParamSchema and publicPostListQuerySchema. The cache key includes the validated locale, type, category, page position, and preview option, keeping different lists separate. The callback passed to forwardWithCache calls the content service when an origin response is needed.10
const cacheKey = `list:${locale}:${type ?? 'any'}:${category ?? 'any'}:${offset ?? '0'}:${limit ?? 'default'}:preview=${preview ? '1' : '0'}`;
return await config.forwardWithCache(
'posts',
cacheKey,
BFF_CACHE_TTL.blogList,
async () => {
const res = await config.safeFetch(`${config.contentSvcUrl}/v1/public/posts/${encodeURIComponent(locale)}${qs}`, {
headers: config.withInternalAuthHeaders(),
});
if (!res.ok) {
config.log.warn({
event: 'PublicBlogList_DependencyFailed',
stage: 'CallDependency',
error: res.error.code,
});
return safeJsonErrorResponse('UPSTREAM_ERROR', 502);
}
return res.value;
},
{ validatePayload: requireNonEmptyObjectArrayFieldPayload('posts') },
);When safeFetch returns a connection failure, this branch returns UPSTREAM_ERROR with HTTP 502. withInternalAuthHeaders() supplies internal-call headers, and validatePayload checks the posts structure for cache storage.
Actual code 3: the content service prepares the list
The public list repository selects rows with status='published'. The following code in content-svc calculates reading time from the body, then returns the list without that body field alongside the total count. Reading-time calculation belongs to the content service on this path.1112
const withReadingTime = result.value.posts.map((post: any) => {
const { content, ...rest } = post;
const readingSource = content || [
post.summary_title,
post.summary_text,
post.description,
post.title,
].filter((value) => typeof value === 'string' && value.trim()).join(' ');
const interactivePreview = preview
? post.content_type === 'lab'
? extractPublicLabPreview(content, post.lab_thumbnail_index)
: extractPublicShaderPreview(content)
: null;
return {
...rest,
...(interactivePreview ? { interactive_preview: interactivePreview } : {}),
reading_time: calculateReadTime(readingSource || ''),
};
});
reply.send({ posts: withReadingTime, total: result.value.total });On this list route, the content service has already prepared the response for the screen. The BFF applies its cache policy and forwards that response.
Ownership of the rules
Part | Responsibility for the MAKONEA public post list |
|---|---|
Browser and apps/web | Display cards and build query options; provide the API proxy for browser calls. |
apps/bff-edge | Validate inputs and manage public-response caching and content-service calls. |
content-svc | Read published posts; return the list without bodies, reading times, and total count. |
PostgreSQL | Store post status and content. |
- Part
Browser and apps/web
- Responsibility for the MAKONEA public post list
Display cards and build query options; provide the API proxy for browser calls.
- Part
apps/bff-edge
- Responsibility for the MAKONEA public post list
Validate inputs and manage public-response caching and content-service calls.
- Part
content-svc
- Responsibility for the MAKONEA public post list
Read published posts; return the list without bodies, reading times, and total count.
- Part
PostgreSQL
- Responsibility for the MAKONEA public post list
Store post status and content.
An API Gateway handles entry rules shared across APIs; a BFF shapes responses for a particular interface. A deployment can put BFFs behind a gateway or combine both roles in one service. GraphQL describes an API and can be used to implement a BFF with resolvers dedicated to an interface.5
Boundaries to maintain in operation
A BFF that starts by assembling a few responses can accumulate more work. SoundCloud's retrospective describes business logic gathering in BFFs above small CRUD services, then being duplicated and drifting between BFFs. When a shared domain concept emerges, a service can take ownership of it while the BFFs retain their interface-specific combinations.1
When a post becomes private, the code deciding visibility and the paths expiring stored responses must agree. The BFF public-data cache and the web HTML cache are separate consumers. Passing an observable failure to the caller, rather than turning an outage into a normal empty list, is another responsibility at this boundary.
Deciding whether to introduce a BFF
When web and mobile need different responses, or several screens repeatedly combine the same services, consider how much screen code and coordination a BFF would remove. If one screen already gets what it needs from one suitable API, a separate BFF has less to justify its deployment and maintenance.
Draw BFF boundaries around the experience and the way its team makes changes. A single team with similar iOS and Android apps can share a BFF; diverging features and release schedules can support separate ones.3
For the notation used in the diagrams, see the UML article.
Personal note
My own website, MAKONEA, also uses a BFF.
Footnotes
- Jorge Creixell. Service Architecture at SoundCloud — Part 1: Backends for Frontends. SoundCloud Backstage, 2021-07-29. Backends for Frontends; The Bad. ↩
- Phil Calçado. The Back-end for Front-end Pattern (BFF). 2015-09-18. The challenges with dogfooding; The Back-end for Front-end Pattern (BFF). ↩
- Sam Newman. Pattern: Backends For Frontends. 2015-11-18. Introducing The Backend For Frontend; How Many BFFs?; And Autonomy. ↩
- Lukasz Plotnicki. BFF @ SoundCloud. Thoughtworks, 2015-12-09. Motivation; Solution; Evolution of a BFF. ↩
- Microsoft. Backends for Frontends pattern. Azure Architecture Center. Context and problem; Solution; Problems and considerations. ↩
- MAKONEA, revision 0b705e8. Next.js public-list proxy. ↩
- MAKONEA, revision 0b705e8. Web content client. ↩
- Object Management Group. Unified Modeling Language 2.5.1. December 2017. Components; Interactions. ↩
- MAKONEA, revision 0b705e8. BFF publicCacheStore. ↩
- MAKONEA, revision 0b705e8. BFF public post routes. ↩
- MAKONEA, revision 0b705e8. Post repository. ↩
- MAKONEA, revision 0b705e8. Content service post controller. ↩