PDF generation in Spring Boot
Thymeleaf renders the HTML, and `StreamingResponseBody` is the idiomatic way to hand a binary back without buffering it — which is what keeps a large render off the heap.
The code
@RestController
class InvoiceController {
private final HttpClient client = HttpClient.newHttpClient();
private final TemplateEngine templates;
@GetMapping(value = "/invoices/{id}.pdf", produces = MediaType.APPLICATION_PDF_VALUE)
ResponseEntity<StreamingResponseBody> invoice(@PathVariable String id) throws Exception {
var context = new Context();
context.setVariable("invoice", repository.findById(id).orElseThrow());
var html = templates.process("invoice", context);
var payload = new ObjectMapper().writeValueAsString(Map.of(
"html", html,
"options", Map.of("printBackground", true,
"margin", Map.of("top", "20mm", "bottom", "20mm"))));
var request = HttpRequest.newBuilder(URI.create("https://api.pdfcraft.dev/v1/render"))
.header("Authorization", "Bearer " + System.getenv("PDFCRAFT_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
var upstream = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (upstream.statusCode() != 200) throw new ResponseStatusException(HttpStatus.BAD_GATEWAY);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=invoice-" + id + ".pdf")
.body(out -> upstream.body().transferTo(out));
}
}What to watch for
- One `HttpClient` as a field, not one per request. A client per request defeats connection pooling and is the usual reason a Java integration is slower than the same call from curl.
- `BodyHandlers.ofInputStream()` plus `transferTo` streams the PDF through without it ever being a `byte[]` on the heap.
- Thymeleaf resolves `@{/css/…}` against the servlet context, which a headless browser cannot reach. Use absolute URLs or inline the CSS.
Why not run the browser yourself
You can. Puppeteer and Playwright both work, and for a script you run by hand they are the right answer. The cost arrives at the point it becomes a service someone depends on: one long-lived browser per process rather than one per request, a semaphore so four renders do not become forty, a hard timeout that force-closes a hung page before it holds a slot forever, a scheduled restart because Chromium leaks, and a Chromium version you are now responsible for patching.
None of that is hard. All of it is work that has nothing to do with your product, and it fails under concurrency rather than in testing — which is the worst time to discover it.
Other frameworks
- PDF generation in Next.js
- PDF generation in Django
- PDF generation in Ruby on Rails
- PDF generation in Laravel
- PDF generation in AWS Lambda
- PDF generation in Cloudflare Workers
- PDF generation in Supabase Edge Functions
- PDF generation in FastAPI
- PDF generation in Express
- PDF generation in ASP.NET Core
- PDF generation in n8n