- Published on
Backend for Frontend with Spring Boot 4: Parallel Aggregation on Java 25 Virtual Threads
- Authors

- Name
- Maria
The Backend for Frontend (BFF) pattern gives each client application an API shaped around its own screen and interaction requirements. This article builds a mobile BFF with Spring Boot 4 and Java 25, then shows how to call independent upstream services concurrently without pretending that ordinary sequential blocking calls are parallel.
The example targets Spring Boot 4.0.7 and Java 25. It uses Spring MVC, RestClient, and a virtual-thread-per-task executor. The same design can be adapted to later Spring Boot 4.x releases, but dependency names and defaults should always be checked against the version used by the project.
TL;DR
- A BFF aggregates and reshapes data for one client experience rather than exposing a generic domain API directly.
- Virtual threads make blocking I/O easier to scale, but they do not automatically make sequential method calls concurrent.
- A production BFF still needs explicit timeouts, a failure policy, observability, and careful propagation of authentication context.
What a BFF should own
A BFF sits between a client and the services that own business data.
Mobile application
|
API gateway or edge proxy
|
Mobile BFF
| | |
User Orders Notifications
service service service
The BFF may own:
- client-specific endpoints;
- aggregation of several upstream responses;
- response shaping and field filtering;
- client-specific caching;
- translation of upstream failures into a stable client contract;
- propagation of identity and authorization information;
- metrics and traces for the complete client request.
The BFF should not become a second domain layer. Core pricing, eligibility, inventory, and payment rules should stay in the services that own those responsibilities. A BFF that accumulates shared business rules becomes difficult to test and duplicates behavior across clients.
BFF, API gateway, and service mesh
These components solve different problems and often coexist.
| Component | Primary responsibility | Typical owner | Common examples |
|---|---|---|---|
| API gateway | Edge routing, rate limiting, coarse authentication, TLS termination | Platform team | Spring Cloud Gateway, Kong, Nginx |
| BFF | Client-specific aggregation and response shaping | Client-facing product team | Mobile BFF, web BFF |
| Service mesh | Service-to-service traffic policy, mTLS, telemetry | Platform team | Istio, Linkerd |
A gateway can route /mobile/** to a mobile BFF and /web/** to a web BFF. The BFF then calls the domain services through the normal internal network or service mesh.
Version baseline and dependencies
Spring Boot 4 provides dedicated starters for Spring MVC and blocking HTTP clients. This example uses the following Maven configuration:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.7</version>
<relativePath />
</parent>
<groupId>com.example</groupId>
<artifactId>mobile-bff</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
This is an imperative Spring MVC application. RestClient is a synchronous client, which fits naturally with virtual threads. A fully reactive WebFlux application should normally keep the reactive model end to end instead of calling .block() inside request handling code.
Enable virtual threads
Configure Spring Boot to use virtual threads for supported task execution and request-processing infrastructure:
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true
clients:
user:
base-url: http://user-service:8081
orders:
base-url: http://order-service:8082
notifications:
base-url: http://notification-service:8083
management:
endpoints:
web:
exposure:
include: health,info,metrics
Virtual threads have been a standard Java feature since Java 21, so Java 25 does not require an -XX:+EnableVirtualThreads flag. The Spring property improves the scalability of blocking request handlers, but it does not make three sequential HTTP calls run at the same time. Explicit concurrency is still required when independent calls should overlap.
Configure blocking HTTP clients with bounded timeouts
A BFF is limited by the services it calls. Every upstream client should have a connection timeout and a response timeout. Without them, one slow dependency can consume the entire client latency budget.
package com.example.mobilebff.config;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
public class UpstreamClientConfiguration {
@Bean
@Qualifier("userRestClient")
RestClient userRestClient(
RestClient.Builder builder,
@Value("${clients.user.base-url}") String baseUrl) {
return buildClient(builder, baseUrl);
}
@Bean
@Qualifier("ordersRestClient")
RestClient ordersRestClient(
RestClient.Builder builder,
@Value("${clients.orders.base-url}") String baseUrl) {
return buildClient(builder, baseUrl);
}
@Bean
@Qualifier("notificationsRestClient")
RestClient notificationsRestClient(
RestClient.Builder builder,
@Value("${clients.notifications.base-url}") String baseUrl) {
return buildClient(builder, baseUrl);
}
private RestClient buildClient(RestClient.Builder builder, String baseUrl) {
var requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(1));
requestFactory.setReadTimeout(Duration.ofSeconds(2));
return builder.clone()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
}
}
For production traffic, a pooled HTTP client such as Apache HttpComponents may be preferable. The important design point is not the specific request factory; it is that connection management and timeouts are deliberate rather than inherited from accidental defaults.
Define the client-facing contract
The mobile screen needs a user profile, recent orders, and an unread-notification count. It also needs to know when optional data was omitted because an upstream service failed.
package com.example.mobilebff.api;
import java.math.BigDecimal;
import java.util.List;
public record UserProfile(
String userId,
String displayName,
String avatarUrl) {
}
public record OrderSummary(
String orderId,
String status,
BigDecimal totalAmount) {
}
public record NotificationCount(int unread) {
}
public record MobileDashboardResponse(
UserProfile profile,
List<OrderSummary> recentOrders,
int unreadNotifications,
List<String> warnings) {
}
The warnings field makes partial responses explicit. Returning an empty list without explaining why can make a dependency outage look like valid business data.
Implement small upstream clients
Each client owns one remote contract. The authorization value is passed explicitly instead of being read from a thread-local security context inside worker threads.
package com.example.mobilebff.client;
import java.util.Objects;
import com.example.mobilebff.api.UserProfile;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class UserClient {
private final RestClient restClient;
public UserClient(@Qualifier("userRestClient") RestClient restClient) {
this.restClient = restClient;
}
public UserProfile getUser(String userId, String authorization) {
return Objects.requireNonNull(
restClient.get()
.uri("/users/{userId}", userId)
.header(HttpHeaders.AUTHORIZATION, authorization)
.retrieve()
.body(UserProfile.class),
"User service returned an empty response body");
}
}
package com.example.mobilebff.client;
import java.util.List;
import java.util.Objects;
import com.example.mobilebff.api.OrderSummary;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class OrdersClient {
private static final ParameterizedTypeReference<List<OrderSummary>> ORDER_LIST =
new ParameterizedTypeReference<>() {
};
private final RestClient restClient;
public OrdersClient(@Qualifier("ordersRestClient") RestClient restClient) {
this.restClient = restClient;
}
public List<OrderSummary> getRecentOrders(String userId, String authorization) {
return Objects.requireNonNull(
restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("userId", userId)
.queryParam("limit", 5)
.build())
.header(HttpHeaders.AUTHORIZATION, authorization)
.retrieve()
.body(ORDER_LIST),
"Orders service returned an empty response body");
}
}
package com.example.mobilebff.client;
import java.util.Objects;
import com.example.mobilebff.api.NotificationCount;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class NotificationsClient {
private final RestClient restClient;
public NotificationsClient(
@Qualifier("notificationsRestClient") RestClient restClient) {
this.restClient = restClient;
}
public int getUnreadCount(String userId, String authorization) {
NotificationCount response = Objects.requireNonNull(
restClient.get()
.uri("/notifications/{userId}/unread-count", userId)
.header(HttpHeaders.AUTHORIZATION, authorization)
.retrieve()
.body(NotificationCount.class),
"Notification service returned an empty response body");
return response.unread();
}
}
Do not log the Authorization header. In a real system, the BFF may relay the incoming token, exchange it for a downstream token, or use workload identity. The correct choice depends on the trust boundaries and authorization model.
Run independent calls concurrently
The profile, order, and notification requests do not depend on one another. They can therefore run on separate virtual threads.
Create a managed executor:
package com.example.mobilebff.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ConcurrencyConfiguration {
@Bean(destroyMethod = "close")
ExecutorService bffIoExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
Then aggregate the calls:
package com.example.mobilebff.service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import com.example.mobilebff.api.MobileDashboardResponse;
import com.example.mobilebff.api.OrderSummary;
import com.example.mobilebff.api.UserProfile;
import com.example.mobilebff.client.NotificationsClient;
import com.example.mobilebff.client.OrdersClient;
import com.example.mobilebff.client.UserClient;
import org.springframework.stereotype.Service;
@Service
public class MobileDashboardService {
private final UserClient userClient;
private final OrdersClient ordersClient;
private final NotificationsClient notificationsClient;
private final ExecutorService bffIoExecutor;
public MobileDashboardService(
UserClient userClient,
OrdersClient ordersClient,
NotificationsClient notificationsClient,
ExecutorService bffIoExecutor) {
this.userClient = userClient;
this.ordersClient = ordersClient;
this.notificationsClient = notificationsClient;
this.bffIoExecutor = bffIoExecutor;
}
public MobileDashboardResponse getDashboard(
String userId,
String authorization) {
CompletableFuture<UserProfile> profileFuture = CompletableFuture
.supplyAsync(
() -> userClient.getUser(userId, authorization),
bffIoExecutor)
.orTimeout(2, TimeUnit.SECONDS);
CompletableFuture<Partial<List<OrderSummary>>> ordersFuture = optionalCall(
"recent orders",
() -> ordersClient.getRecentOrders(userId, authorization),
List.of());
CompletableFuture<Partial<Integer>> notificationsFuture = optionalCall(
"notification count",
() -> notificationsClient.getUnreadCount(userId, authorization),
0);
UserProfile profile = awaitRequiredProfile(profileFuture);
Partial<List<OrderSummary>> orders = ordersFuture.join();
Partial<Integer> notifications = notificationsFuture.join();
List<String> warnings = new ArrayList<>();
orders.addWarningTo(warnings);
notifications.addWarningTo(warnings);
return new MobileDashboardResponse(
profile,
orders.value(),
notifications.value(),
List.copyOf(warnings));
}
private <T> CompletableFuture<Partial<T>> optionalCall(
String component,
Supplier<T> supplier,
T fallback) {
return CompletableFuture
.supplyAsync(supplier, bffIoExecutor)
.orTimeout(1500, TimeUnit.MILLISECONDS)
.handle((value, error) -> error == null
? new Partial<>(value, null)
: new Partial<>(fallback, component + " is temporarily unavailable"));
}
private UserProfile awaitRequiredProfile(
CompletableFuture<UserProfile> profileFuture) {
try {
return profileFuture.join();
} catch (CompletionException exception) {
throw new RequiredUpstreamUnavailableException(
"The user profile service is unavailable",
exception.getCause());
}
}
private record Partial<T>(T value, String warning) {
void addWarningTo(List<String> warnings) {
if (warning != null) {
warnings.add(warning);
}
}
}
}
package com.example.mobilebff.service;
public class RequiredUpstreamUnavailableException extends RuntimeException {
public RequiredUpstreamUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
This code makes the user profile mandatory while treating orders and notifications as optional. That is a product decision, not a universal BFF rule. A checkout BFF may consider an inventory or price response mandatory and fail the entire request instead of returning stale or partial data.
Why this is actually parallel
The three calls are submitted to a virtual-thread-per-task executor before the method waits for their results. If each upstream service takes approximately 500 ms, the aggregation time should be closer to the slowest call than to the sum of all three calls, excluding connection setup and local overhead.
By contrast, this code is still sequential even when the current request runs on a virtual thread:
UserProfile profile = userClient.getUser(userId, authorization);
List<OrderSummary> orders = ordersClient.getRecentOrders(userId, authorization);
int unread = notificationsClient.getUnreadCount(userId, authorization);
Virtual threads make blocking less expensive. They do not reorder statements or infer which calls are safe to run concurrently.
Expose the mobile endpoint
package com.example.mobilebff.api;
import com.example.mobilebff.service.MobileDashboardService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/mobile/v1")
public class MobileDashboardController {
private final MobileDashboardService dashboardService;
public MobileDashboardController(MobileDashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/users/{userId}/dashboard")
MobileDashboardResponse getDashboard(
@PathVariable @NotBlank String userId,
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
return dashboardService.getDashboard(userId, authorization);
}
}
Map a required upstream failure to a stable HTTP response:
package com.example.mobilebff.api;
import com.example.mobilebff.service.RequiredUpstreamUnavailableException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class BffExceptionHandler {
@ExceptionHandler(RequiredUpstreamUnavailableException.class)
ProblemDetail handleRequiredUpstreamFailure(
RequiredUpstreamUnavailableException exception) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
problem.setTitle("Required upstream service unavailable");
problem.setDetail(exception.getMessage());
return problem;
}
}
A 502 Bad Gateway communicates that the BFF is reachable but cannot complete the request because a required dependency failed. Avoid returning raw exception messages or internal hostnames to the client.
Timeout and fallback rules
A BFF should define a latency budget for the complete endpoint and allocate smaller budgets to each dependency.
For example:
| Operation | Budget | Failure policy |
|---|---|---|
| User profile | 2.0 seconds | Fail the request |
| Recent orders | 1.5 seconds | Return an empty list with a warning |
| Notification count | 1.5 seconds | Return zero with a warning |
| Complete BFF response | 2.3 seconds | Abort and report a gateway failure |
The table is only an example. Production values should come from service-level objectives and observed latency distributions.
CompletableFuture.orTimeout limits how long the aggregation waits, but it is not a substitute for HTTP-level timeouts. The remote request can continue consuming resources if the underlying client has no bounded connection and read timeouts.
Retries also need restraint. Retrying every dependency from the BFF can multiply traffic during an outage. Retry only operations that are safe, only for transient conditions, and with a small attempt limit and jitter. Do not retry non-idempotent writes unless the complete operation has a reliable idempotency strategy.
Security context and virtual threads
Thread-local context is easy to misuse when a BFF creates its own worker threads. The example captures the incoming authorization value at the controller boundary and passes it explicitly to each client. This avoids relying on SecurityContextHolder inside independently created tasks.
For a production security design:
- validate the incoming access token at the edge or BFF;
- verify that the caller may access the requested user or tenant;
- decide whether to relay, exchange, or replace the token for downstream calls;
- never include tokens in logs, traces, exception messages, or metrics labels;
- use short-lived credentials and least-privilege scopes.
If a framework-specific context-propagation mechanism is used, test it under the exact executor and virtual-thread configuration used in production.
Observability for aggregation endpoints
A BFF can hide the source of latency because one client request fans out to several services. At minimum, record:
- total endpoint latency;
- latency and outcome for each upstream call;
- partial-response count;
- timeout count by dependency;
- required-dependency failure count;
- distributed trace identifiers;
- request correlation identifiers that do not contain personal data.
Spring Boot Actuator can instrument supported HTTP clients when they are created from the auto-configured builders. Distributed tracing is especially valuable because it shows whether the user, orders, or notification service dominated the request.
Avoid high-cardinality metric tags such as userId, order identifiers, access tokens, or arbitrary URLs. Those values can create unbounded metric series and may expose sensitive information.
How to verify the concurrency behavior
A useful local test is to make each mock upstream endpoint wait for a known duration.
- Configure the user, order, and notification stubs to each respond after 500 ms.
- Call the BFF endpoint once to warm up connections.
- Measure several requests rather than relying on a single result.
- Confirm that the total time is near the slowest dependency, not approximately 1.5 seconds.
- Stop the notification service and verify that the endpoint returns a warning rather than failing.
- Stop the user service and verify that the endpoint returns
502.
Example request:
curl -i \
-H "Authorization: Bearer test-token" \
http://localhost:8080/mobile/v1/users/user-123/dashboard
Example partial response:
{
"profile": {
"userId": "user-123",
"displayName": "Avery",
"avatarUrl": "/avatars/user-123.png"
},
"recentOrders": [],
"unreadNotifications": 0,
"warnings": [
"recent orders is temporarily unavailable"
]
}
Do not publish throughput numbers unless the test setup, hardware, concurrency level, warm-up period, and measurement method are documented. Virtual threads often improve scalability for I/O-bound workloads, but they do not make remote services faster and they are not intended to accelerate CPU-bound work.
Common failure modes
The endpoint is still slow after enabling virtual threads
Check whether the calls are truly submitted concurrently. Also inspect DNS resolution, connection pooling, TLS handshakes, upstream latency, and database contention. Virtual threads only address the cost of blocked Java threads.
Requests time out even though the upstream service eventually succeeds
Compare the BFF's complete latency budget with each HTTP client's connection and read timeouts. A timeout that is shorter than normal tail latency will create avoidable failures. A timeout that is too long will make the client wait through an outage.
Authentication works in the controller but fails in worker tasks
Do not assume thread-local security state is automatically present in a separately created executor. Capture the required identity information before starting the tasks or configure and test an explicit context-propagation mechanism.
Partial responses look like legitimate empty data
Include an explicit warning or availability field. Also emit a metric and trace event so operations teams can distinguish a real empty order list from an orders-service failure.
The BFF has become a monolith
Review ownership boundaries. Move reusable domain rules back to the domain services, split unrelated client journeys, and remove endpoints that merely proxy upstream responses without providing client-specific value.
When a BFF is not the right choice
A BFF may be unnecessary when:
- all clients use the same contract and release cadence;
- the API gateway already performs the small amount of required shaping;
- the client needs only one upstream service;
- the additional deployment and ownership cost exceeds the benefit;
- GraphQL or another composition layer already provides a well-governed solution.
Adding a BFF by default can create more services without reducing coupling. Use it when there is a clear client-specific contract, performance, or team-ownership problem to solve.
Conclusion
A BFF is valuable when it gives a client a stable, purpose-built contract and removes unnecessary network chatter or transformation logic from the frontend. Spring Boot 4 and Java 25 virtual threads make an imperative implementation practical for I/O-heavy aggregation, but correct concurrency still has to be expressed explicitly.
The production concerns matter as much as the happy-path code: bounded timeouts, a documented partial-response policy, safe identity propagation, observability, and tests that prove the calls actually overlap. With those boundaries in place, a BFF can improve client performance without becoming another source of hidden coupling.