Skip to content
Spring Boot sb reactive 3 min read

Reactive REST APIs

With Spring WebFlux and Project Reactor you can build fully non-blocking REST APIs: every layer — controller, service, data access, downstream HTTP — returns a Mono or Flux, and no thread ever waits on I/O. This page walks through a CRUD resource, streaming responses, calling other services, and handling errors reactively.

A non-blocking CRUD endpoint

Each handler returns a publisher. The service delegates to a reactive repository (see R2DBC) that itself returns Mono/Flux, so the whole chain is non-blocking end to end.

@RestController
@RequestMapping("/api/products")
class ProductController {

    private final ProductService service;

    ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    Flux<Product> findAll() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    Mono<ResponseEntity<Product>> findById(@PathVariable String id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    Mono<Product> create(@Valid @RequestBody Mono<ProductRequest> body) {
        return body.flatMap(service::create);
    }

    @PutMapping("/{id}")
    Mono<ResponseEntity<Product>> update(@PathVariable String id,
                                         @Valid @RequestBody Mono<ProductRequest> body) {
        return body.flatMap(req -> service.update(id, req))
                .map(ResponseEntity::ok)
                .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    Mono<Void> delete(@PathVariable String id) {
        return service.delete(id);
    }
}

DTOs are still plain records with validation constraints; @Valid works on the deserialized body.

public record ProductRequest(
        @NotBlank String name,
        @Positive BigDecimal price) { }

Streaming with Server-Sent Events

The real superpower of Flux over the wire is streaming. By producing text/event-stream, the server pushes items to the client one at a time as they are produced — no buffering the whole collection in memory. This is ideal for live feeds, progress updates, or large result sets.

@GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<PriceTick> streamPrices() {
    return Flux.interval(Duration.ofSeconds(1))
            .map(seq -> new PriceTick("BTC", 64000 + seq));
}

Output: the connection stays open and the client receives events as they arrive:

data:{"symbol":"BTC","price":64000}

data:{"symbol":"BTC","price":64001}

data:{"symbol":"BTC","price":64002}

Backpressure protects the server: if the client reads slowly, Reactor slows the source rather than piling up memory. A plain Flux<Product> returned as JSON, by contrast, is buffered into a single [ ... ] array.

Calling downstream services with WebClient

In a reactive app you must use a non-blocking HTTP client. That is WebClient (never the blocking RestTemplate). It returns Mono/Flux, so downstream calls slot straight into your pipeline.

@Service
class InventoryService {

    private final WebClient client;

    InventoryService(WebClient.Builder builder) {
        this.client = builder.baseUrl("https://inventory.internal").build();
    }

    Mono<Stock> stockFor(String productId) {
        return client.get()
                .uri("/stock/{id}", productId)
                .retrieve()
                .bodyToMono(Stock.class)
                .timeout(Duration.ofSeconds(2))
                .onErrorResume(ex -> Mono.just(Stock.unknown(productId)));
    }
}

Combine an inventory lookup with the product fetch concurrently using zip:

Mono<ProductView> view = Mono.zip(
        service.findById(id),
        inventory.stockFor(id))
    .map(t -> new ProductView(t.getT1(), t.getT2()));

See WebClient for the full client API, headers, and auth.

Error handling

You have two complementary tools: operators inside the pipeline and a centralized @RestControllerAdvice.

Inline with operators

service.findById(id)
    .switchIfEmpty(Mono.error(new ProductNotFoundException(id)))
    .onErrorResume(DownstreamUnavailable.class,
                   ex -> Mono.just(Product.placeholder(id)));

Centralized with @ExceptionHandler

@RestControllerAdvice works in WebFlux just as in MVC. Exceptions signalled anywhere in the reactive chain are routed here.

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    ProblemDetail handleNotFound(ProductNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        pd.setTitle("Product not found");
        pd.setDetail(ex.getMessage());
        return pd;
    }
}

Output:

{
  "type": "about:blank",
  "title": "Product not found",
  "status": 404,
  "detail": "No product with id 42"
}

Tip: Return ProblemDetail (RFC 9457) for consistent, machine-readable error bodies across both reactive and blocking APIs.

Warning: Never call .block() inside a controller or service to “simplify” things — it blocks a Netty event loop thread and undermines the entire reactive stack. Keep the pipeline reactive all the way down.

Last updated June 13, 2026
Was this helpful?