Spring Boot Screenshot API: RestClient, PDF and @Async

Screenshots and HTML-to-PDF in Spring Boot 4 without an SDK: a RestClient bean, streamed responses, @Async on virtual threads, MockRestServiceServer tests.

By Simon Chiu · Updated

The Java screenshot API page uses the rs-java SDK. This page does the same work in Spring Boot with no SDK at all, because many teams prefer one HTTP client that they already know, test and trace. You get four parts: a RestClient bean, a controller that streams images and PDFs without holding them in memory, @Async captures on virtual threads, and tests with MockRestServiceServer. A short signed-URL component covers og:image tags.

Last tested: 2026-10-19 with OpenJDK 21.0.2, Maven 3.9.16, Spring Boot 4.1.1 (Spring Framework 7.0.9) and spring-boot-starter-test. mvn test passed all six tests. We also started the full application against a local stub of our API and saw the three @Async captures run on virtual threads. We did not call the live API for this page.

What each part is for

Job Spring piece API call
og:image for your own pages Controller that streams a PNG POST /v1/screenshot with url and preset
Invoices, receipts, reports Controller that streams a PDF POST /v1/screenshot with html and output.format: pdf
Archive a list of pages @Async service on virtual threads One request per URL, capped
Social cards that crawlers load directly Signed-URL component GET /v1/screenshot, no request to your app

Dependencies and properties

Only spring-boot-starter-web is needed, plus spring-boot-starter-test for the tests. RestClient, MockRestServiceServer and HtmlUtils are part of Spring Framework.

# src/main/resources/application.properties
rs.api-key=${RS_API_KEY}
rs.base-url=${RS_BASE_URL:https://api.renderscreenshot.com}
rs.signing-key=${RS_SIGNING_KEY}
rs.public-key-id=${RS_PUBLIC_KEY_ID}
spring.threads.virtual.enabled=true
# @Async tasks run on virtual threads; cap how many render at once
spring.task.execution.simple.concurrency-limit=4

RS_BASE_URL exists only so that a local stub can stand in for the API.

The RestClient bean

@SpringBootApplication
@EnableAsync
public class ShotsApplication {

    @Bean
    RestClient renderScreenshot(@Value("${rs.base-url}") String baseUrl, @Value("${rs.api-key}") String apiKey) {
        return configure(RestClient.builder(), baseUrl, apiKey).build();
    }

    /** Shared by the bean and the tests, so MockRestServiceServer can bind to the same builder. */
    static RestClient.Builder configure(RestClient.Builder builder, String baseUrl, String apiKey) {
        var factory = new JdkClientHttpRequestFactory();
        factory.setReadTimeout(Duration.ofSeconds(90)); // above the API's 60-second maximum wait
        return builder
            .requestFactory(factory)
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey);
    }

    public static void main(String[] args) {
        SpringApplication.run(ShotsApplication.class, args);
    }
}

The configure method is the important design choice. The tests call it with their own builder, so they exercise the same base URL, header and timeout as production. The read timeout must be longer than the slowest render. The API waits at most 60 seconds for a page (wait options), so 90 seconds leaves room for the network.

A controller that streams PNGs and PDFs

@RestController
class ShotController {

    private static final Logger log = LoggerFactory.getLogger(ShotController.class);
    private static final Pattern SLUG = Pattern.compile("[a-z0-9-]{1,80}");

    private final RestClient api;

    ShotController(RestClient api) {
        this.api = api;
    }

    /** A page of your site as an Open Graph PNG, streamed straight from the API. */
    @GetMapping("/og/{slug}.png")
    void ogImage(@PathVariable String slug, HttpServletResponse out) {
        if (!SLUG.matcher(slug).matches()) {
            out.setStatus(404);
            return;
        }
        stream(Map.of("url", "https://example.com/blog/" + slug, "preset", "og_card"),
            "public, max-age=86400", out);
    }

    /** HTML you build on the server, as an A4 PDF. */
    @PostMapping("/invoices/pdf")
    void invoicePdf(@RequestParam String customer, @RequestParam String amount, HttpServletResponse out) {
        String html = """
            <!doctype html><meta charset="utf-8">
            <style>body{font:14px system-ui;margin:0} h1{font-size:22px}</style>
            <h1>Invoice for %s</h1><p>Amount due: %s</p>
            """.formatted(HtmlUtils.htmlEscape(customer), HtmlUtils.htmlEscape(amount));
        stream(Map.of(
            "html", html,
            "output", Map.of("format", "pdf", "filename", "invoice"),
            "pdf", Map.of("paper", "a4", "margin", "1.5cm", "print_background", true)),
            "private, no-store", out);
    }

    private void stream(Map<String, Object> body, String cacheControl, HttpServletResponse out) {
        api.post().uri("/v1/screenshot")
            .contentType(MediaType.APPLICATION_JSON)
            .body(body)
            .exchange((request, response) -> {
                int status = response.getStatusCode().value();
                if (status == 200) {
                    out.setContentType(response.getHeaders().getFirst("Content-Type"));
                    out.setHeader("Cache-Control", cacheControl);
                    var disposition = response.getHeaders().getFirst("Content-Disposition");
                    if (disposition != null) out.setHeader("Content-Disposition", disposition);
                    response.getBody().transferTo(out.getOutputStream()); // no byte[] in the heap
                    return null;
                }
                log.warn("render failed: {} {}", status, new String(response.getBody().readAllBytes()));
                if (status == 429) {
                    out.setHeader("Retry-After", response.getHeaders().getFirst("Retry-After"));
                    out.setStatus(503);
                } else {
                    out.setStatus(502);
                }
                return null;
            });
    }
}

Imports are left out; they are all from org.springframework, jakarta.servlet, org.slf4j and java.util.

Why it is built this way:

  • exchange(), not body(byte[].class). exchange gives you the open response stream, and transferTo copies it to the servlet output as it arrives. A 20-page PDF or a long full-page PNG never sits in the heap as one array. exchange also does not throw on 4xx or 5xx, so one lambda handles both outcomes.
  • The slug check is the security boundary. Visitors choose the slug, and only [a-z0-9-] reaches the URL. Without it, anyone could point your key at any page and spend your credits.
  • The HTML is escaped. customer comes from a form. HtmlUtils.htmlEscape turns <script> into text before the HTML goes to Chrome. If you render with Thymeleaf, its th:text escapes for you.
  • The PDF options follow the API. paper, margin and print_background live in the pdf object, and output.filename makes the API send a Content-Disposition header, which the controller passes on. All options are on the PDF parameters page. The PDF generation API page compares this approach with other PDF engines.
  • A 429 becomes a 503 with Retry-After. The rate limit is per account, and all your keys share it (rate limits). A web request should not wait 30 seconds and retry. It should tell the client or CDN when to come back. The logged body holds the API's error.code; the error codes index explains each one.

Many captures with @Async on virtual threads

For a list of URLs, such as a nightly archive, call a service method once per URL and let Spring run each call on its own virtual thread:

@Service
public class BulkCapture {

    private final RestClient api;

    BulkCapture(RestClient api) {
        this.api = api;
    }

    /** Runs on a virtual thread from Spring's task executor; returns when the file is on disk. */
    @Async
    public CompletableFuture<Path> capture(String url, Path file) throws Exception {
        for (int attempt = 1; ; attempt++) {
            try {
                byte[] png = api.post().uri("/v1/screenshot")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of("url", url, "preset", "full_page"))
                    .retrieve()
                    .body(byte[].class);
                return CompletableFuture.completedFuture(Files.write(file, png));
            } catch (HttpClientErrorException e) {
                if (e.getStatusCode() != HttpStatus.TOO_MANY_REQUESTS || attempt == 4) throw e;
                String wait = e.getResponseHeaders().getFirst("Retry-After");
                Thread.sleep(Long.parseLong(wait == null ? "5" : wait) * 1000); // cheap on a virtual thread
            }
        }
    }
}
@Component
public class Archiver {

    private final BulkCapture bulk;

    Archiver(BulkCapture bulk) {
        this.bulk = bulk;
    }

    /** Starts one @Async capture per URL and waits for all of them. */
    public List<Path> archive(List<String> urls, Path dir) throws Exception {
        var futures = new ArrayList<CompletableFuture<Path>>();
        for (int i = 0; i < urls.size(); i++) {
            futures.add(bulk.capture(urls.get(i), dir.resolve("page-" + i + ".png")));
        }
        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
        return futures.stream().map(CompletableFuture::join).toList();
    }
}

Three points about this pair:

  1. The call must cross a Spring proxy. @Async works only when another bean calls the method, which is why Archiver is a separate class. A call from inside BulkCapture itself would run on the caller's thread.
  2. The two properties do the threading. With spring.threads.virtual.enabled=true, Boot's task executor starts a virtual thread for each task. In our run against the stub, all three captures logged VirtualThread[...]. spring.task.execution.simple.concurrency-limit caps how many run at the same time. Keep it small: the API limits requests per minute for the whole account (rate limits reference), and each of these captures is one request.
  3. Sleeping is fine here. A virtual thread that waits out Retry-After does not hold an OS thread. Here retrieve() throws HttpClientErrorException for any 4xx, which suits a job: the job fails loudly on a bad URL and retries only a 429.

allOf(...).join() throws if any capture fails, but the other files are still written. For more than a few dozen URLs, the batch endpoint is better: one POST /v1/batch counts as one request against the per-minute limit, and our side does the fan-out. For queues that must survive a restart, see screenshots in background jobs.

Signed og:image URLs

A controller that streams images still runs one request through your app for each crawler fetch. A signed URL sends the crawler to the API directly. This component signs URLs with the documented algorithm, without the SDK:

@Component
public class OgImageUrls {

    private final String endpoint;
    private final String keyId;
    private final SecretKeySpec key;

    OgImageUrls(@Value("${rs.base-url}") String baseUrl,
                @Value("${rs.public-key-id}") String keyId,      // rs_pub_...
                @Value("${rs.signing-key}") String signingKey) { // rs_secret_...
        this.endpoint = baseUrl + "/v1/screenshot";
        this.keyId = keyId;
        this.key = new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    /** Same URL for a whole UTC day, valid 7 to 8 days (the maximum is 30). */
    public String forPage(String pageUrl) {
        Instant expires = Instant.now().truncatedTo(ChronoUnit.DAYS).plus(Duration.ofDays(8));
        return sign(Map.of("url", pageUrl, "preset", "og_card"), expires);
    }

    String sign(Map<String, String> params, Instant expires) {
        var fields = new TreeMap<String, String>(params);
        fields.put("key_id", keyId);
        fields.put("expires", Long.toString(expires.getEpochSecond()));
        String query = fields.entrySet().stream()
            .map(e -> encode(e.getKey()) + "=" + encode(e.getValue()))
            .sorted()
            .collect(Collectors.joining("&"));
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(key);
            byte[] digest = mac.doFinal(query.getBytes(StandardCharsets.UTF_8));
            return endpoint + "?" + query + "&signature=" + HexFormat.of().formatHex(digest);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }

    // Form encoding as the server does it: space -> "+", only A-Z a-z 0-9 - _ . ~ kept.
    private static String encode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("*", "%2A").replace("%7E", "~");
    }
}

Put ogImageUrls.forPage(...) into your model and render it in the og:image meta tag. The expiry is aligned to the UTC day, so every page view on the same day gets the same URL, and crawlers and CDNs can cache the image. Signed URLs work only for GET and HEAD requests, and expire after 30 days at most.

We found one startup trap while we tested this. If RS_SIGNING_KEY is empty, SecretKeySpec throws IllegalArgumentException: Empty key, and the context does not start. That is why the property has no default: a missing key stops the deploy instead of producing broken images.

Tests with MockRestServiceServer

The tests bind MockRestServiceServer to a builder from configure(...) and run the controller with standalone MockMvc. They need no Spring context and no network:

class ShotControllerTest {

    private MockRestServiceServer api;
    private MockMvc mvc;

    @BeforeEach
    void setUp() {
        RestClient.Builder builder = ShotsApplication.configure(RestClient.builder(), "https://api.test", "rs_live_test");
        api = MockRestServiceServer.bindTo(builder).build();
        mvc = MockMvcBuilders.standaloneSetup(new ShotController(builder.build())).build();
    }

    @Test
    void streamsThePngWithCacheHeaders() throws Exception {
        api.expect(requestTo("https://api.test/v1/screenshot"))
            .andExpect(method(HttpMethod.POST))
            .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer rs_live_test"))
            .andExpect(MockRestRequestMatchers.content().json("""
                {"url": "https://example.com/blog/hello-world", "preset": "og_card"}"""))
            .andRespond(withSuccess(new byte[] {(byte) 0x89, 'P', 'N', 'G'}, MediaType.IMAGE_PNG));

        mvc.perform(get("/og/hello-world.png"))
            .andExpect(status().isOk())
            .andExpect(header().string("Cache-Control", "public, max-age=86400"))
            .andExpect(content().bytes(new byte[] {(byte) 0x89, 'P', 'N', 'G'}));
        api.verify();
    }

    @Test
    void turnsA429IntoA503WithRetryAfter() throws Exception {
        api.expect(requestTo("https://api.test/v1/screenshot"))
            .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header("Retry-After", "12"));

        mvc.perform(get("/og/hello-world.png"))
            .andExpect(status().isServiceUnavailable())
            .andExpect(header().string("Retry-After", "12"));
    }

    @Test
    void escapesUserInputInThePdfHtml() throws Exception {
        api.expect(requestTo("https://api.test/v1/screenshot"))
            .andExpect(MockRestRequestMatchers.jsonPath("$.html").value(containsString("&lt;script&gt;")))
            .andExpect(MockRestRequestMatchers.jsonPath("$.output.format").value("pdf"))
            .andRespond(withSuccess("%PDF-1.7", MediaType.APPLICATION_PDF));

        mvc.perform(post("/invoices/pdf").param("customer", "<script>x</script>").param("amount", "$40"))
            .andExpect(status().isOk())
            .andExpect(header().string("Content-Type", "application/pdf"));
    }
}

Both MockRestRequestMatchers and MockMvcResultMatchers define header(), content() and jsonPath(). If you import both with a wildcard, the compiler reports "reference to content is ambiguous". So the listing imports only requestTo and method statically, and it writes the class name for the API-side matchers.

The full suite has three more tests. One sends a bad slug and checks that no request reached the API. One calls BulkCapture.capture directly with a Retry-After: 0 response, followed by a success. The last checks OgImageUrls.sign against the reference vector in the signed URL docs, and our signature matched it. For contract tests in other languages, see the SDK testing guide.

When to use the SDK instead

Use rs-java if you want typed responses, takeJson() with the CDN URL, and batch helpers without writing JSON maps. The Java screenshot API page shows it with a Spring controller and Mockito. Use the RestClient approach on this page if your team already standardizes on RestClient for observability, retries and testing, or if you want to stream responses.

Get a free API key, set it as RS_API_KEY, and run mvn spring-boot:run. The free plan includes 150 credits every month.

Frequently asked questions

How do I take a screenshot of a web page in Spring Boot?
Send a POST to /v1/screenshot with a RestClient that carries your API key as a Bearer header, then copy the response body into the HttpServletResponse. Chrome runs on the API side, so the Spring Boot app needs no browser, Playwright or Selenium.
How do I generate a PDF from HTML in Spring Boot?
Build the HTML on the server, escape every value that comes from a user with HtmlUtils.htmlEscape, and send it as the html field with output.format set to pdf. Paper size, margins and background printing go in the pdf object.
Can @Async screenshot jobs run on virtual threads?
Yes. With spring.threads.virtual.enabled=true, Spring Boot's task executor starts each @Async call on a virtual thread. Set spring.task.execution.simple.concurrency-limit to a small number so that a long URL list does not use up your account's per-minute request limit in the first seconds.
How do I test RestClient calls to a screenshot API?
Bind MockRestServiceServer to the RestClient.Builder that your app uses, declare the request you expect and the response to return, and run the controller with standalone MockMvc. No Spring context and no network are needed.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.