Java Screenshot API: SDK, Virtual Threads, Spring Boot

Capture website screenshots from Java 21: the rs-java SDK, HttpClient streaming to a file, virtual-thread fan-out, a Spring Boot controller and JUnit mocks.

By Simon Chiu · Updated

This page shows how to take website screenshots from Java with RenderScreenshot. The examples target Java 21: the official rs-java SDK, java.net.http.HttpClient for a dependency-free call, virtual threads for many captures at once, a Spring Boot controller with a signed og:image in a Thymeleaf template, and JUnit 5 tests with Mockito. Other languages are on the screenshot API code examples hub.

Last tested: 2026-10-18 with OpenJDK 21.0.2, Maven 3.9.16, rs-java v2.0.3 from JitPack, Spring Boot 4.1.1, JUnit Jupiter 6.0.3 and Mockito 5.23.0. The application and the demos ran against a local stub of our API that returns the documented image, batch and error responses, including a 429 with Retry-After. mvn test passed. We did not call the live API for this page.

Install

The SDK is published through JitPack. With Maven:

<repositories>
  <repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
  </repository>
</repositories>

<dependency>
  <groupId>com.github.Render-Screenshot</groupId>
  <artifactId>rs-java</artifactId>
  <version>v2.0.3</version>
</dependency>

With Gradle (Kotlin DSL):

repositories {
    mavenCentral()
    maven("https://jitpack.io")
}

dependencies {
    implementation("com.github.Render-Screenshot:rs-java:v2.0.3")
}

Use v2.0.3 or later. The changelog for 2.0.3 says that older versions did not read the API's nested error body, so getCode() returned null. The SDK itself runs on Java 11; the examples below use virtual threads and text blocks, so they need Java 21. The SDK brings OkHttp and Jackson 2. In a Spring Boot 4.1.1 build, Maven resolved Jackson 2.21.5 through Boot's dependency management, and the SDK worked with it.

Quick start

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.TakeOptions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

public class QuickStart {
    public static void main(String[] args) throws Exception {
        Client client = new Client(System.getenv("RS_API_KEY"), null, Duration.ofSeconds(60));
        byte[] png = client.take(
            TakeOptions.url("https://example.com").preset("og_card").blockCookieBanners());
        Files.write(Path.of("example-og.png"), png);
    }
}

take() returns byte[] and throws the unchecked RenderScreenshotException. A null base URL means the default endpoint. The Java SDK reference lists every builder method, and the presets reference shows what og_card sets.

Errors: what the exception tells you

RenderScreenshotException has getHttpStatus(), getCode(), getMessage(), isRetryable() and getRetryAfter(). We sent three failing requests to our stub:

Stub response getHttpStatus() getCode() isRetryable() getRetryAfter()
429 with Retry-After: 1 429 too_many_requests true 1
422 target timeout 422 timeout false null
400 bad URL 400 invalid_url false null

The Java SDK derives isRetryable() from the status: true for 429 and for 5xx. That is the right default, and it differs from some of our other SDKs, which compare the code string. A 422 means the target page failed (it timed out, it refused, its certificate was bad). Retrying it at once rarely helps. The retries guide covers when it does, and each code has a page in the error codes index.

Many captures on virtual threads

take() blocks, and virtual threads make blocking calls cheap. One virtual thread per URL keeps the code linear. A Semaphore caps how many requests are in flight, because the API limits concurrent requests per plan:

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.RenderScreenshotException;
import com.renderscreenshot.sdk.TakeOptions;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;

public final class Fanout {

    private final Client client;
    private final Semaphore permits;

    public Fanout(Client client, int maxInFlight) {
        this.client = client;
        this.permits = new Semaphore(maxInFlight);
    }

    /** One virtual thread per URL; the semaphore caps requests in flight. */
    public Map<String, byte[]> captureAll(List<String> urls) throws InterruptedException {
        Map<String, Future<byte[]>> pending = new LinkedHashMap<>();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (String url : urls) {
                pending.put(url, executor.submit(() -> capture(url)));
            }
        } // close() waits for every task

        Map<String, byte[]> images = new LinkedHashMap<>();
        for (var entry : pending.entrySet()) {
            try {
                images.put(entry.getKey(), entry.getValue().get());
            } catch (ExecutionException e) {
                System.err.println(entry.getKey() + " failed: " + e.getCause().getMessage());
            }
        }
        return images;
    }

    byte[] capture(String url) throws InterruptedException {
        for (int attempt = 1; ; attempt++) {
            try {
                return takeWithPermit(url);
            } catch (RenderScreenshotException e) {
                if (!e.isRetryable() || attempt == 3) {
                    throw e;
                }
                Integer wait = e.getRetryAfter(); // seconds, set on a 429
                Thread.sleep(1000L * (wait != null ? wait : 1 << attempt));
            }
        }
    }

    private byte[] takeWithPermit(String url) throws InterruptedException {
        permits.acquire();
        try {
            return client.take(TakeOptions.url(url).preset("og_card"));
        } finally {
            permits.release();
        }
    }
}

The permit covers only the request, not the sleep. A thread that is waiting out a Retry-After does not hold a slot that another URL could use. Against the stub, four URLs (one 429, one invalid URL) gave three images and one logged failure, and the run took about 1.1 seconds, which is the one-second Retry-After. Pick maxInFlight from the concurrent limit of your plan in the rate limits reference. The per-minute limit counts every key of the account together. For more than a handful of URLs at a time, the batch endpoint (client.batch(urls, options)) does the fan-out on our side; for durable queues, see screenshots in background jobs.

Raw HTTP: stream to a file with HttpClient

Without the SDK, java.net.http is enough. BodyHandlers.ofFile writes the image to disk as it arrives, so a long full-page capture never sits in the heap:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

/** No SDK: java.net.http streams the image straight to a file. */
public final class RawCapture {

    private static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .build();

    public static void main(String[] args) throws Exception {
        String api = System.getenv().getOrDefault("RS_BASE_URL", "https://api.renderscreenshot.com");
        String json = """
            {"url": "https://example.com", "capture": {"mode": "full_page"}, "output": {"format": "png"}}
            """;
        HttpRequest request = HttpRequest.newBuilder(URI.create(api + "/v1/screenshot"))
            .timeout(Duration.ofSeconds(90))
            .header("Authorization", "Bearer " + System.getenv("RS_API_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        Path out = Path.of("example-full.png");
        HttpResponse<Path> response = HTTP.send(request, HttpResponse.BodyHandlers.ofFile(out));
        if (response.statusCode() != 200) {
            String error = Files.readString(out); // the JSON error body landed in the file
            Files.delete(out);
            throw new IllegalStateException(response.statusCode() + " " + error);
        }
        System.out.println("saved " + Files.size(out) + " bytes, title: "
            + response.headers().firstValue("x-screenshot-title").orElse("?"));
    }
}

ofFile writes whatever the server sends, including an error body, so the code reads the file back on a non-200 status and deletes it. The request timeout of 90 seconds is longer than the API's maximum render wait of 60 seconds. HTTP.sendAsync(...) returns a CompletableFuture if you prefer callbacks to virtual threads. The JSON body follows POST /v1/screenshot.

Spring Boot

The client as a bean

@SpringBootApplication
public class ShotsApplication {

    @Bean
    Client renderScreenshot(@Value("${rs.api-key}") String apiKey,
                            @Value("${rs.base-url:#{null}}") String baseUrl) {
        return new Client(apiKey, baseUrl, Duration.ofSeconds(60));
    }

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

One shared client means one OkHttp connection pool. rs.base-url is optional; we set it only to point the app at our stub.

A controller that serves the image

@RestController
class OgImageController {

    private static final Logger log = LoggerFactory.getLogger(OgImageController.class);
    private static final Pattern SLUG = Pattern.compile("[a-z0-9-]+");

    private final Client client;

    OgImageController(Client client) {
        this.client = client;
    }

    @GetMapping("/og/{slug}.png")
    ResponseEntity<byte[]> og(@PathVariable String slug) {
        if (!SLUG.matcher(slug).matches()) {
            return ResponseEntity.notFound().build();
        }
        byte[] png = client.take(
            TakeOptions.url("https://example.com/blog/" + slug).preset("og_card"));
        return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_PNG)
            .cacheControl(CacheControl.maxAge(Duration.ofDays(1)).cachePublic())
            .body(png);
    }

    @ExceptionHandler(RenderScreenshotException.class)
    ResponseEntity<Void> renderFailed(RenderScreenshotException e) {
        log.warn("render failed: {} {} {}", e.getHttpStatus(), e.getCode(), e.getMessage());
        HttpStatus status = e.getHttpStatus() == 429
            ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_GATEWAY;
        return ResponseEntity.status(status).build();
    }
}

The slug check keeps visitors from pointing your key at any URL. With spring.threads.virtual.enabled=true, Tomcat serves each request on a virtual thread, so a slow render does not tie up a platform thread.

A signed og:image in a Thymeleaf template

A signed URL lets crawlers load the image from the API directly, with no request to your app. The URL holds your rs_pub_ key ID and an HMAC signature, never the API key:

@Controller
class BlogController {

    private final Client signer;

    BlogController(@Value("${rs.api-key}") String apiKey,
                   @Value("${rs.signing-key}") String signingKey,     // rs_secret_...
                   @Value("${rs.public-key-id}") String publicKeyId) { // rs_pub_...
        this.signer = new Client(apiKey, null, null, signingKey, publicKeyId);
    }

    @GetMapping("/blog/{slug}")
    String post(@PathVariable String slug, Model model) {
        // Start of the UTC day + 8 days: the URL changes once a day, never per request.
        Instant expires = Instant.now().truncatedTo(ChronoUnit.DAYS).plus(Duration.ofDays(8));
        model.addAttribute("ogImage", signer.generateUrl(
            TakeOptions.url("https://example.com/blog/" + slug).preset("og_card"), expires));
        return "blog/post";
    }
}
<!-- src/main/resources/templates/blog/post.html -->
<meta property="og:image" th:content="${ogImage}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

Thymeleaf escapes each & as &amp; in the attribute, which is correct HTML. The day-aligned expiry is deliberate: Instant.now().plus(...) would sign a new URL on every page view, and crawlers would see a different image each time. Signed URLs live at most 30 days. As a cross-check, this controller and our Go example produced the same signature for the same page and expiry.

Test with JUnit 5 and Mockito

Client is not final, so Mockito mocks it without extra setup. Because the controller takes the client in its constructor, the tests need no Spring context:

class OgImageControllerTest {

    private final Client client = mock(Client.class);

    @Test
    void returnsThePng() {
        when(client.take(any(TakeOptions.class))).thenReturn(new byte[] {1, 2, 3});
        var response = new OgImageController(client).og("hello-world");
        assertEquals(200, response.getStatusCode().value());
        assertArrayEquals(new byte[] {1, 2, 3}, response.getBody());
    }

    @Test
    void mapsRateLimitTo503() {
        var limited = new RenderScreenshotException("slow down", 429, "too_many_requests", true, 30);
        assertEquals(503, new OgImageController(client).renderFailed(limited).getStatusCode().value());
    }

    @Test
    void fanoutRetriesAfter429() throws Exception {
        var limited = new RenderScreenshotException("slow down", 429, "too_many_requests", true, 0);
        when(client.take(any(TakeOptions.class))).thenThrow(limited).thenReturn(new byte[] {9});

        var images = new Fanout(client, 2).captureAll(List.of("https://example.com"));

        assertArrayEquals(new byte[] {9}, images.get("https://example.com"));
        verify(client, times(2)).take(any(TakeOptions.class));
    }
}

A Retry-After of 0 keeps the retry test instant. On JDK 21, Mockito prints a warning that it attaches its agent at runtime; the tests still pass, and Mockito's documentation explains how to add the agent to the Surefire configuration. For HTTP-level contract tests, see the SDK testing guide.

Source, changelog and next steps

Get a free API key to run these examples against the real API. The free plan comes with 150 credits a month.

Frequently asked questions

How do I add the RenderScreenshot Java SDK to Maven or Gradle?
Add the JitPack repository and the dependency com.github.Render-Screenshot:rs-java with a release tag such as v2.0.3. The SDK runs on Java 11 or later.
Which rs-java version should I use?
v2.0.3 or later. Its changelog notes that earlier versions did not read the API's nested error body, so getCode() returned null on API errors.
Can I use virtual threads with the Java SDK?
Yes. take() is a blocking call, which is what virtual threads are for. Start one virtual thread per URL and cap requests in flight with a Semaphore, because the API limits concurrency per plan.
Can I mock the Java SDK client in JUnit?
Yes. Client is a regular, non-final class, so Mockito can mock it directly. Inject it as a Spring bean so tests can pass a mock in its place.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.