Spring Boot
Response Handling
Everything a controller returns passes through an HttpMessageConverter that turns it into the response body, usually JSON via Jackson. What you control is the shape around that body: the status code, the headers, the error format, the caching rules, and whether the response is produced synchronously, asynchronously or as a stream. This entry covers return types, status codes, RFC 9457 error responses, headers and file downloads, HTTP caching, shaping JSON, and async and streaming responses, including the mistakes that tend to slip through code review.
Return Types: Plain Object or ResponseEntity
Spring picks a converter by matching the client's Accept header against what the method can produce; Jackson handles JSON, and a request the server cannot satisfy gets 406 Not Acceptable. Return DTOs or records, not JPA entities, so the JSON is a deliberate contract and lazy relationships never leak into serialisation.
Which return type to use depends on whether the status or headers vary. When a method always answers with the same status, return the object directly and add @ResponseStatus if the status is not 200; the signature stays honest and the code stays short. Switch to ResponseEntity when the status, headers or presence of a body depend on what happens at runtime, such as a 404 for a missing record or a Location header after creation.
One trap deserves attention. Returning Optional<T> does not produce a 404 for an empty value. Spring unwraps the Optional and, if it is empty, sends 200 with an empty body, which clients tend to treat as a successful, blank result. Map it to a 404 yourself, or let the service throw an exception that your error handler turns into one.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// Fixed outcome: return the object. 200 OK is implied.
// userService.findById throws UserNotFoundException when missing (see the errors section).
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
// Fixed non-200 status: annotate instead of wrapping.
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@RequestBody @Valid CreateUserRequest request) {
return userService.create(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
// Outcome varies: ResponseEntity makes the 200 or 404 explicit.
@GetMapping("/by-email")
public ResponseEntity<UserResponse> findByEmail(@RequestParam String email) {
return userService.findOptionalByEmail(email)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
// Gotcha: an empty Optional here is 200 with an empty body, NOT 404.
@GetMapping("/optional/{id}")
public Optional<UserResponse> findOptional(@PathVariable Long id) {
return userService.findOptional(id);
}
}Choosing Status Codes
Clients, proxies and monitoring all act on the status code, so a 200 that carries an error message in its body defeats every one of them. A short list covers most APIs.
200 for a successful read or an update that returns the new state. 201 after creating a resource, with a Location header pointing at it. 202 when work was accepted but is not finished, ideally with a Location for a status resource the client can poll. 204 for success with nothing to return, which is the usual answer to DELETE and often to PUT and PATCH. 400 for a malformed request or failed validation, 401 and 403 for authentication and authorisation, 404 for a missing resource, 409 for a conflict such as a duplicate or a stale version, and 422 if you want to separate well-formed-but-invalid requests from malformed ones. Pick one convention for validation errors and apply it everywhere.
For one-off errors you can throw ResponseStatusException straight from a controller. Anything you throw in more than one place deserves a named exception and a central handler, covered next. A UriComponentsBuilder parameter is filled in by Spring from the current request, which makes it the easy way to build a correct absolute Location URL.
Java
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
// 201 Created + Location of the new resource
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest request,
UriComponentsBuilder uriBuilder) {
OrderResponse created = orderService.create(request);
URI location = uriBuilder.path("/api/v1/orders/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
// 202 Accepted: the work continues in the background. Point the client at a status URL.
@PostMapping("/{id}/ship")
public ResponseEntity<Void> ship(@PathVariable Long id, UriComponentsBuilder uriBuilder) {
orderService.scheduleShipment(id);
URI status = uriBuilder.path("/api/v1/orders/{id}/shipment")
.buildAndExpand(id).toUri();
return ResponseEntity.accepted().location(status).build();
}
// 204 No Content: success with nothing to say
@PatchMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void patch(@PathVariable Long id, @RequestBody @Valid PatchOrderRequest request) {
orderService.patch(id, request);
}
// 404 from a one-off check
@GetMapping("/{id}")
public OrderResponse find(@PathVariable Long id) {
return orderService.findOptional(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order " + id + " not found"));
}
// A batch endpoint: 207 Multi-Status comes from WebDAV and few clients understand it.
// Returning 200 with a per-item result list is usually friendlier.
@PostMapping("/batch-cancel")
public List<BatchResult> batchCancel(@RequestBody List<Long> ids) {
return orderService.cancelBatch(ids);
}
}Error Responses with ProblemDetail
Spring 6 and Boot 3 support RFC 9457 (previously RFC 7807) "problem details", a standard JSON error shape with type, title, status, detail and instance, plus any extra fields you add. Using it means every endpoint fails in the same recognisable format and clients can parse errors generically.
Extend ResponseEntityExceptionHandler in a @RestControllerAdvice. It already maps Spring's own exceptions (bad JSON, wrong media type, missing parameters, validation failures) to correct statuses with ProblemDetail bodies, and you add handlers for your own exceptions on top. Override the validation hook if you want the per-field errors in the response. Always finish with a catch-all that logs the exception and returns a generic 500: the stack trace belongs in your logs, not in the response. If you do not need a custom advice class, setting spring.mvc.problemdetails.enabled=true turns on the same format for Spring's built-in exceptions.
Java
@RestControllerAdvice
@Slf4j
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order not found");
problem.setProperty("orderId", ex.getOrderId()); // extra fields are allowed
return problem;
}
@ExceptionHandler(OptimisticLockingFailureException.class)
ProblemDetail handleConflict(OptimisticLockingFailureException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "The resource was modified by someone else. Reload and retry.");
problem.setTitle("Conflict");
return problem;
}
// Validation failures (@Valid on a request body): add the field errors to Spring's default body.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.putIfAbsent(e.getField(), e.getDefaultMessage()));
ProblemDetail problem = ex.getBody();
problem.setTitle("Validation failed");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
// Last resort: log the details, tell the client nothing sensitive.
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
}
}
// Example response:
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
// { "type": "about:blank", "title": "Order not found", "status": 404,
// "detail": "Order 42 not found", "instance": "/api/v1/orders/42", "orderId": 42 }Response Headers and File Downloads
Set headers through the ResponseEntity builder, which keeps them next to the status and body. Injecting HttpServletResponse also works but spreads the response across two objects, so reserve it for cases the builder cannot express.
For downloads, build Content-Disposition with ContentDisposition rather than concatenating strings: it quotes and encodes the filename correctly, including non-ASCII names, and avoids header injection from a user-supplied filename. For files users upload, do not trust the stored content type. Serving an uploaded HTML or SVG file inline from your own origin can run its scripts in your site's context; send application/octet-stream with attachment unless you have validated the type.
If the body is a Resource, you get range requests for free. When a client sends a Range header, Spring answers with 206 Partial Content and the right Content-Range and Accept-Ranges headers, which is what makes video seeking and resumable downloads work. You do not write that logic yourself, and doing it by hand is a common source of bugs.
One browser detail: JavaScript on another origin can only read a handful of standard response headers. Custom ones such as X-File-Id must be listed in the CORS configuration's exposed headers or the frontend will see nothing.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
// Download. Because the body is a Resource, Range requests are answered with 206 automatically.
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
StoredFile file = fileService.find(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(file.filename(), StandardCharsets.UTF_8) // quotes and encodes safely
.build().toString())
.contentType(MediaType.parseMediaType(file.contentType()))
.contentLength(file.sizeBytes())
.body(file.resource());
}
// Inline preview: still use ContentDisposition, never string concatenation.
@GetMapping("/{id}/preview")
public ResponseEntity<Resource> preview(@PathVariable Long id) {
StoredFile file = fileService.find(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline()
.filename(file.filename(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType()))
.body(file.resource());
}
// Creation with Location and a few custom headers (header values must be Strings).
@PostMapping
public ResponseEntity<FileResponse> upload(@RequestParam MultipartFile file,
UriComponentsBuilder uriBuilder) {
FileResponse stored = fileService.store(file);
URI location = uriBuilder.path("/api/v1/files/{id}").buildAndExpand(stored.id()).toUri();
return ResponseEntity.created(location)
.header("X-File-Id", String.valueOf(stored.id()))
.header("X-Checksum", stored.checksum())
.body(stored);
}
}
// CORS: let browser code on another origin read your custom headers.
// registry.addMapping("/api/**").exposedHeaders("X-File-Id", "X-Checksum", "Location");HTTP Caching: Cache-Control, ETag and Last-Modified
Caching headers tell browsers and proxies what they may reuse. Cache-Control sets the rules: how long a response is fresh, and whether shared caches may store it at all. Use cachePublic for responses identical for everyone, cachePrivate for anything personalised so a shared proxy never serves one user's data to another, and noStore for sensitive data. An ETag or Last-Modified lets a client revalidate cheaply: it asks "has this changed?" and gets a bodyless 304 if not.
Spring does part of this for you. When a GET handler returns a ResponseEntity with an ETag or Last-Modified header, Spring compares it with If-None-Match or If-Modified-Since and converts the response to 304 automatically, handling weak validators and lists of values that hand-written string comparisons get wrong. That saves bandwidth but not work: your handler already ran and built the body. To skip the work too, call checkNotModified before loading anything, using a cheap version check such as a version column or an updated-at timestamp. It returns true if it has already written the 304, and you return null to stop.
If you only want bandwidth savings across many endpoints with no per-endpoint effort, ShallowEtagHeaderFilter computes an ETag from every response body. And if Spring Security is on the classpath it adds no-cache headers by default; a Cache-Control you set yourself takes precedence, but check the actual response headers in a browser or with curl -I to be sure.
Java
@RestController
@RequestMapping("/api/v1/catalogue")
@RequiredArgsConstructor
public class CatalogueController {
private final ProductService productService;
// Cheap revalidation: check the version first, load the body only if the client is out of date.
@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> product(@PathVariable Long id, WebRequest request) {
long version = productService.currentVersion(id); // one small query, not the whole object
if (request.checkNotModified(String.valueOf(version))) {
return null; // 304 already written; Spring quotes the ETag for you
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePublic())
.eTag(String.valueOf(version))
.body(productService.findById(id));
}
// Last-Modified variant
@GetMapping("/categories")
public ResponseEntity<List<CategoryResponse>> categories(WebRequest request) {
Instant lastModified = productService.categoriesLastModified();
if (request.checkNotModified(lastModified.toEpochMilli())) {
return null;
}
return ResponseEntity.ok()
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(30)).cachePublic())
.body(productService.findAllCategories());
}
// Per-user data: only the user's own browser may keep it.
@GetMapping("/me/recommendations")
public ResponseEntity<List<ProductResponse>> recommendations(@AuthenticationPrincipal AppUser user) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(1)).cachePrivate())
.body(productService.recommendationsFor(user.getId()));
}
// Sensitive: never stored anywhere.
@GetMapping("/me/wishlist")
public ResponseEntity<WishlistResponse> wishlist(@AuthenticationPrincipal AppUser user) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(productService.wishlistFor(user.getId()));
}
}Shaping the JSON
Spring Boot configures Jackson sensibly out of the box: dates are written as ISO-8601 strings rather than timestamps and unknown properties in requests are ignored. Most of the shaping then happens on the response DTOs themselves, which is another reason to use them instead of entities. Records work well and Jackson reads them without extra annotations.
Use annotations for exceptions to the rule: @JsonProperty to rename a field, @JsonInclude to drop nulls, @JsonIgnore for anything that must never leave the server. Prefer a separate DTO per audience over @JsonView, which is easy to get subtly wrong. For global conventions such as snake_case names or omitting nulls everywhere, set them once in configuration instead of repeating annotations.
Money deserves a note. A BigDecimal is written as a JSON number, and JavaScript clients parse numbers as binary floating point, so large or high-precision values can silently lose digits. Serialising amounts as strings, or as integer minor units like cents, avoids that.
Java
public record ProductResponse(
Long id,
String name,
@JsonProperty("image_url") // rename one field
String imageUrl,
@JsonInclude(JsonInclude.Include.NON_NULL) // omit when null
String discountLabel,
@JsonFormat(shape = JsonFormat.Shape.STRING) // "19.99" instead of 19.99, safe for JS clients
BigDecimal price,
Instant createdAt) { // written as ISO-8601, e.g. 2026-10-04T08:30:00Z
}
// Global defaults in application.yml instead of per-field annotations:
//
// spring:
// jackson:
// default-property-inclusion: non_null # omit nulls everywhere
// property-naming-strategy: SNAKE_CASE # createdAt -> created_at
//
// Never serialise secrets by accident: keep password hashes, tokens and internal flags out of the
// response DTO entirely. @JsonIgnore on an entity is a safety net, not a design.Async, Streaming and Server-Sent Events
Returning an async type releases the servlet thread while the work happens elsewhere. It does not make the work itself cheaper, and the other thread needs somewhere to run. On Java 21 with Spring Boot 3.2 or later, check spring.threads.virtual.enabled=true first: with virtual threads, plain blocking controller code scales well and most of the machinery below becomes unnecessary.
When you do need it, Callable is the simplest option, because Spring runs it on its own async executor. CompletableFuture suits services that are already asynchronous; pass your own executor to supplyAsync rather than relying on the shared common pool. DeferredResult lets any thread complete the response later, but you must complete it on every path. If the future fails and you only handle success, the request just hangs until the timeout. Report a timeout as 503 or 504, since 408 means the client was too slow to send its request.
StreamingResponseBody writes straight to the output stream so a large export never sits in memory. It runs on another thread after the controller has returned, which has two consequences. There is no request transaction there, so a lazy database stream must be opened inside the service within its own @Transactional(readOnly = true) method, and closed. And you should not hand-build CSV with printf: names with commas or quotes corrupt the file, and %.2f follows the server's locale. Use a CSV library.
SseEmitter pushes events to the browser over a long-lived connection. Treat it as a resource with a lifecycle: send from another thread, complete it when finished, and remove it from whatever registry holds it on completion, timeout and error, or you will leak one per dropped client. Reverse proxies may buffer the stream, so disable proxy buffering for that path.
Java
@RestController
@RequestMapping("/api/v1/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
// Simplest offload: Spring runs the Callable on its async executor.
@GetMapping("/{id}")
public Callable<ReportResponse> generate(@PathVariable Long id) {
return () -> reportService.generate(id);
}
// DeferredResult: complete it on EVERY path, or the request hangs until the timeout.
@GetMapping("/{id}/deferred")
public DeferredResult<ReportResponse> deferred(@PathVariable Long id) {
DeferredResult<ReportResponse> result = new DeferredResult<>(30_000L);
result.onTimeout(() -> result.setErrorResult(
new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Report generation timed out")));
reportService.generateAsync(id).whenComplete((report, error) -> {
if (error != null) {
result.setErrorResult(error); // goes through your exception handlers
} else {
result.setResult(report);
}
});
return result;
}
// Streaming export. The service opens its own read-only transaction and closes the stream.
@GetMapping(value = "/export", produces = "text/csv")
public ResponseEntity<StreamingResponseBody> export() {
StreamingResponseBody body = out -> {
try (Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
CSVPrinter csv = new CSVPrinter(writer, CSVFormat.DEFAULT.builder()
.setHeader("id", "name", "total", "createdAt").build())) {
reportService.forEachRow(row -> { // @Transactional(readOnly = true) inside
try {
csv.printRecord(row.id(), row.name(),
row.total().toPlainString(), // locale-independent
row.createdAt());
} catch (IOException e) {
throw new UncheckedIOException(e); // typically the client disconnecting
}
});
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("report.csv").build().toString())
.body(body);
}
// Server-Sent Events: manage the emitter's whole lifecycle.
@GetMapping(value = "/{id}/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter progress(@PathVariable Long id) {
SseEmitter emitter = new SseEmitter(120_000L);
reportService.subscribe(id, emitter); // events are sent from another thread
Runnable cleanup = () -> reportService.unsubscribe(id, emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(emitter::complete);
emitter.onError(ex -> cleanup.run());
return emitter;
}
}
// Behind nginx, stop it from buffering the event stream:
// proxy_buffering off; (or send the header X-Accel-Buffering: no)