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-javav2.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 withRetry-After.mvn testpassed. 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 & 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
- SDK source: github.com/Render-Screenshot/rs-java, with releases on JitPack.
- Running a browser from Java yourself instead: Playwright vs Selenium.
Get a free API key to run these examples against the real API. The free plan comes with 150 credits a month.