Skip to content

Java Web Scraping: Jsoup, HttpClient, Playwright and Selenium (Tested Code)

A practical guide to Java web scraping: Jsoup for HTML, java.net.http for polite crawling with retries, virtual threads for speed, Playwright and Selenium for JavaScript pages, and calling cloud scrapers from Java. Every snippet tested on JDK 25 on 26 October 2026.

Published
Reading time
10 min read
Pixel-art robot with an orange >_ on its terminal head pulls a data card out of a monitor showing the Java coffee cup logo with orange steam above lines of code, and drops it into a crate
In this article
  1. Java web scraping libraries compared
  2. Set up a Java scraping project
  3. Your first scraper with Jsoup
  4. Jsoup selectors worth knowing
  5. Crawl every page: HttpClient, retries and politeness
  6. Faster: fetch pages in parallel with virtual threads
  7. Scrape JavaScript pages: Playwright and Selenium for Java
  8. Playwright for Java
  9. Selenium
  10. Run Java web scraping jobs in the cloud with Scrape.ly
  11. What the run returned
  12. What to watch out for
  13. FAQ
  14. Is Java good for web scraping?
  15. What is the best Java library for web scraping?
  16. How do I scrape a JavaScript-rendered page in Java?
  17. Java vs Python for web scraping: which is better?
  18. Is web scraping with Java legal?

Java web scraping comes down to two jobs: download a page, then pull structured data out of its HTML. For most sites, Jsoup does both in a few lines, Java's built-in HttpClient gives you control over headers, timeouts and retries, and Playwright or Selenium take over when the data only appears after JavaScript runs.

This guide builds a scraper step by step, from a 15-line Jsoup script to a crawler that walks all 50 pages of a catalogue and writes a CSV. Then it covers concurrency with virtual threads, headless browsers, and how to call ready-made cloud scrapers from Java when you'd rather not run the infrastructure yourself. Every snippet was run on 26 October 2026 with Eclipse Temurin JDK 25.0.5 against books.toscrape.com and quotes.toscrape.com, two sandbox sites built for practice.

Java web scraping libraries compared

Library What it does Use it when Version tested
Jsoup Fetches pages and parses HTML with CSS selectors The data is in the page source 1.23.2
java.net.http.HttpClient HTTP client in the JDK (Java 11+) You want retries, custom headers and HTTP/2 without a dependency JDK 25.0.5
Playwright for Java Drives Chromium, Firefox or WebKit The page renders its data with JavaScript 1.64.0
Selenium Drives a real browser over WebDriver You already use Selenium or Selenium Grid 4.51.0

Start with Jsoup. Pair it with HttpClient once you crawl more than a handful of pages, and add a browser only when a site forces you to.

Set up a Java scraping project

You need a JDK 17 or newer for the code below (it uses records and text blocks; the virtual-thread example needs 21). With Maven, add Jsoup to pom.xml:

html
<dependency>
  <groupId>org.jsoup</groupId>
  <artifactId>jsoup</artifactId>
  <version>1.23.2</version>
</dependency>

With Gradle it's implementation("org.jsoup:jsoup:1.23.2"). For a quick experiment you don't need a build tool at all: download the jar from Maven Central and run a single source file directly, which is how we ran the first two examples:

shell
curl -LO https://repo1.maven.org/maven2/org/jsoup/jsoup/1.23.2/jsoup-1.23.2.jar
java -cp jsoup-1.23.2.jar FirstScraper.java

Your first scraper with Jsoup

Open books.toscrape.com, right-click a book and choose Inspect. Each book sits in an <article class="product_pod">, the full title is in the title attribute of the link inside <h3>, and the price is in <p class="price_color">:

Text
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

public class FirstScraper {
    public static void main(String[] args) throws Exception {
        Document doc = Jsoup.connect("https://books.toscrape.com/")
                .userAgent("books-tutorial/1.0 (you@example.com)")
                .timeout(15_000)
                .get();

        for (Element card : doc.select("article.product_pod")) {
            String title = card.selectFirst("h3 a").attr("title");
            String price = card.selectFirst("p.price_color").text();
            String url = card.selectFirst("h3 a").absUrl("href");
            System.out.println(title + " | " + price + " | " + url);
        }
        System.out.println(doc.select("article.product_pod").size() + " books on this page");
    }
}

The first lines of output:

Text
A Light in the Attic | £51.77 | https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet | £53.74 | https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
Soumission | £50.10 | https://books.toscrape.com/catalogue/soumission_998/index.html
...
20 books on this page

Three things Jsoup does for you here. absUrl("href") resolves the relative link against the page's URL, so you never glue URLs together by hand. The pound sign comes out right, because Jsoup detects the encoding from the page's meta tag even though this server sends no charset in its headers. And select throws nothing when a selector matches nothing: selectFirst returns null, which is your cue to validate records before you save them.

Jsoup selectors worth knowing

Jsoup speaks CSS plus a few extras that save you from writing loops. On a book's detail page, doc.selectFirst("th:containsOwn(UPC) + td").text() finds the table cell next to the "UPC" header and returned a897fe39b1053632. :has(...) filters by descendants (article.product_pod:has(p.star-rating.Five)), eachAttr("title") collects one attribute from every match into a list, and className() returns the full class string, which matters on this site because the star rating lives in a class name (star-rating Three).

Crawl every page: HttpClient, retries and politeness

Jsoup.connect() is fine for a few pages. For a real crawl, use the JDK's HttpClient for downloading and Jsoup only for parsing. You get one connection pool for the whole run, explicit timeouts, and full control over retries. This crawler follows the next link through the catalogue, retries 429 and 5xx responses with backoff, waits a second between requests and writes a CSV:

Text
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

public class BooksCrawler {
    record Book(String title, double price, int rating, boolean inStock, String url) {}

    static final Map<String, Integer> RATINGS = Map.of("One", 1, "Two", 2, "Three", 3, "Four", 4, "Five", 5);

    static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

    static String fetch(String url) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .header("User-Agent", "books-tutorial/1.0 (you@example.com)")
                .timeout(Duration.ofSeconds(15))
                .GET()
                .build();
        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response =
                    CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            int status = response.statusCode();
            if (status == 200) return response.body();
            boolean retryable = status == 429 || status >= 500;
            if (!retryable || attempt == 3) throw new IOException("HTTP " + status + " for " + url);
            Thread.sleep(1000L << attempt); // back off: 2 s, then 4 s
        }
    }

    static List<Book> parse(Document doc) {
        List<Book> books = new ArrayList<>();
        for (Element card : doc.select("article.product_pod")) {
            Element link = card.selectFirst("h3 a");
            books.add(new Book(
                    link.attr("title"),
                    Double.parseDouble(card.selectFirst("p.price_color").text().replace("£", "")),
                    RATINGS.get(card.selectFirst("p.star-rating").classNames().stream()
                            .filter(RATINGS::containsKey).findFirst().orElse("One")),
                    card.selectFirst("p.availability").text().contains("In stock"),
                    link.absUrl("href")));
        }
        return books;
    }

    public static void main(String[] args) throws Exception {
        int maxPages = args.length > 0 ? Integer.parseInt(args[0]) : 50;
        String url = "https://books.toscrape.com/catalogue/page-1.html";
        List<Book> all = new ArrayList<>();

        for (int page = 1; page <= maxPages && url != null; page++) {
            Document doc = Jsoup.parse(fetch(url), url); // base URI makes absUrl() work
            all.addAll(parse(doc));
            Element next = doc.selectFirst("li.next a");
            url = next == null ? null : next.absUrl("href");
            Thread.sleep(1000); // one request per second
        }

        try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of("books.csv")))) {
            out.println("title,price,rating,in_stock,url");
            for (Book b : all) {
                out.printf(Locale.ROOT, "\"%s\",%.2f,%d,%b,%s%n",
                        b.title().replace("\"", "\"\""), b.price(), b.rating(), b.inStock(), b.url());
            }
        }
        System.out.println(all.size() + " books saved to books.csv");
        System.out.println(all.get(0));
    }
}

Run without arguments, it fetched all 50 pages and printed:

Text
1000 books saved to books.csv
Book[title=A Light in the Attic, price=51.77, rating=3, inStock=true, url=https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html]

The whole run took 107 seconds on our connection, 50 of them the deliberate one-second pauses. Pass a number (java ... BooksCrawler 5) to stop early while you test. What each piece is for:

  • Jsoup.parse(html, url) parses a string you downloaded yourself. The second argument is the base URI, without which absUrl() returns an empty string.
  • The retry loop repeats only what is worth repeating: 429 (slow down) and 5xx (server trouble). A 404 fails at once. The wait doubles each time, so you back off instead of hammering a struggling server.
  • BodyHandlers.ofString(UTF_8) decodes the body explicitly. HttpClient falls back to UTF-8 when no charset is sent, but being explicit documents the assumption.
  • Locale.ROOT in printf. Our first run wrote 51,77 instead of 51.77 into the CSV, because %.2f follows the machine's locale and the test Mac was set to German. Any number you format for a file needs a fixed locale.
  • Thread.sleep(1000) keeps you at about one request per second. On a sandbox it's courtesy; on a real site it's what keeps your IP off a block list.

Faster: fetch pages in parallel with virtual threads

When you know the URLs up front, fetch several at once. Virtual threads (Java 21+) make each request cheap to run in its own thread, and a Semaphore caps how many hit the server at the same time:

Text
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;

import org.jsoup.Jsoup;

public class ParallelPages {
    static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
    static final Semaphore LIMIT = new Semaphore(4); // at most 4 requests in flight

    static List<String> titles(int page) throws Exception {
        String url = "https://books.toscrape.com/catalogue/page-" + page + ".html";
        HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15)).build();
        LIMIT.acquire();
        try {
            String html = CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)).body();
            return Jsoup.parse(html, url).select("article.product_pod h3 a").eachAttr("title");
        } finally {
            LIMIT.release();
        }
    }

    public static void main(String[] args) throws Exception {
        long start = System.nanoTime();
        List<String> all = new ArrayList<>();
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<List<String>>> pages = new ArrayList<>();
            for (int page = 1; page <= 10; page++) {
                int p = page;
                pages.add(pool.submit(() -> titles(p)));
            }
            for (Future<List<String>> f : pages) all.addAll(f.get());
        }
        System.out.printf(Locale.ROOT, "%d titles in %.1f s, first: %s%n",
                all.size(), (System.nanoTime() - start) / 1e9, all.get(0));
    }
}

It printed 200 titles in 1.8 s, first: A Light in the Attic. Keep the semaphore small on real sites: four parallel requests to one host is already more than many small sites expect.

Scrape JavaScript pages: Playwright and Selenium for Java

Open quotes.toscrape.com/js and view the source: the quotes aren't in it, a script writes them into the page. Jsoup only ever sees that empty source, so you need a real browser.

Before you reach for one, check the browser's Network tab for a JSON endpoint. Many JavaScript pages load their data from an API you can call with HttpClient directly; the infinite-scroll version of the quotes site, for example, reads https://quotes.toscrape.com/api/quotes?page=1.

Playwright for Java

Add com.microsoft.playwright:playwright:1.64.0 to your build. On first run, Playwright downloads the browsers it needs:

Text
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;

public class QuotesPlaywright {
    public static void main(String[] args) {
        try (Playwright playwright = Playwright.create()) {
            Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
            Page page = browser.newPage();
            page.navigate("https://quotes.toscrape.com/js/");
            page.waitForSelector("div.quote"); // rendered by JavaScript

            for (Locator quote : page.locator("div.quote").all()) {
                String text = quote.locator("span.text").innerText();
                String author = quote.locator("small.author").innerText();
                System.out.println(author + ": " + text);
            }
            System.out.println(page.locator("div.quote").count() + " quotes");
        }
    }
}

It printed all 10 quotes on the page, starting with Albert Einstein, and 10 quotes. waitForSelector is the important line: it waits until the script has rendered the element, instead of guessing with a fixed sleep.

Selenium

With org.seleniumhq.selenium:selenium-java:4.51.0, Selenium Manager finds or downloads a matching ChromeDriver for you, so there's no driver to install by hand:

Text
import java.time.Duration;
import java.util.List;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class QuotesSelenium {
    public static void main(String[] args) {
        ChromeOptions options = new ChromeOptions().addArguments("--headless=new");
        WebDriver driver = new ChromeDriver(options);
        try {
            driver.get("https://quotes.toscrape.com/js/");
            new WebDriverWait(driver, Duration.ofSeconds(10))
                    .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("div.quote")));

            List<WebElement> quotes = driver.findElements(By.cssSelector("div.quote"));
            for (WebElement quote : quotes) {
                System.out.println(quote.findElement(By.cssSelector("small.author")).getText()
                        + ": " + quote.findElement(By.cssSelector("span.text")).getText());
            }
            System.out.println(quotes.size() + " quotes");
        } finally {
            driver.quit();
        }
    }
}

Same 10 quotes. If you're choosing between the two, our Playwright vs Selenium comparison runs the same scraper in both; the short version is that Playwright's auto-waiting and browser management make scraping code shorter. And if you want to know what "headless" actually changes, read what a headless browser is.

Run Java web scraping jobs in the cloud with Scrape.ly

The code above runs on your machine. Running it every day, from IPs that don't get blocked, with storage, retries and an alert when a redesign breaks a selector, is a different job. Scrape.ly runs that layer for you, and your Java code calls it over a plain REST API with java.net.http. No SDK needed.

For big platforms, the Store has 570+ ready-made scrapers (Actors), maintained by their publishers. For any other public site, the Web Scraper Actor fetches pages over HTTP through Scrape.ly's residential proxy and runs a small JavaScript page function you write. This Java program runs it on the Travel category of books.toscrape.com and gets the rows back in the same request:

Text
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class RunActor {
    public static void main(String[] args) throws Exception {
        String token = System.getenv("SCRAPELY_TOKEN");
        String input = """
            {
              "startUrls": [{"url": "https://books.toscrape.com/catalogue/category/books/travel_2/index.html"}],
              "pageFunction": "async function pageFunction(context) { const $ = context.jQuery; return $('article.product_pod').map((i, el) => ({ title: $(el).find('h3 a').attr('title'), price: $(el).find('p.price_color').text(), inStock: $(el).find('p.availability').text().includes('In stock') })).get(); }",
              "proxyConfiguration": {"useScrapelyProxy": true}
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(URI.create(
                        "https://api.scrape.ly/v2/actors/querystone~web-scraper/run-sync-get-dataset-items"))
                .header("Authorization", "Bearer " + token)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(60))
                .POST(HttpRequest.BodyPublishers.ofString(input))
                .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode());
        System.out.println(response.body()); // a JSON array, one object per book
    }
}

Create a token under Settings → API & Integrations and export it as SCRAPELY_TOKEN. The page function returns an array, and each element becomes one row. Parse the JSON with Jackson or Gson, or add ?format=csv to the URL to get CSV instead.

What the run returned

We ran this exact input on 26 October 2026. The run took 7.6 seconds, fetched 1 page and saved 11 books. The first five rows:

title price inStock
It's Only the Himalayas £45.17 true
Full Moon over Noah’s Ark: An Odyssey to Mount Ararat and Beyond £49.43 true
See America: A Celebration of Our National Parks & Treasured Sites £48.87 true
Vagabonding: An Uncommon Guide to the Art of Long-Term World Travel £36.94 true
Under the Tuscan Sun £37.33 true

From the run log:

Text
2026-10-26T14:33:56.579Z [Scrapely] Container started (setup 0.9 s)
2026-10-26T14:33:58.377Z [scrapely.proxy_configuration] INFO  Platform proxy access check succeeded.
2026-10-26T14:34:02.922Z [scrapely.actor] INFO  Done: 1 pages fetched, 11 records saved in 5 s
2026-10-26T14:34:02.922Z [scrapely.actor] INFO  [Status message]: Saved 11 results from 1 page.

Web Scraper costs $0.000715 per result, with platform usage (compute, proxy) included. So this run was 11 × $0.000715 = $0.0079 at list price (we were charged a little less, which reflects a plan discount). The free plan's $5 of monthly credit covers about 7,000 such rows.

For jobs that take longer than about 30 seconds, don't hold a request open. Start the run with POST https://api.scrape.ly/v2/actors/querystone~web-scraper/runs, read the run ID from data.id, poll https://api.scrape.ly/v2/actor-runs/RUN_ID?waitForFinish=60 until the status is SUCCEEDED, then fetch https://api.scrape.ly/v2/actor-runs/RUN_ID/dataset/items. Three plain HTTP calls, the same HttpClient code.

Why route a Java scraper through Scrape.ly instead of running it yourself:

  • No blocking work on your side. Requests go through a residential proxy, and the Scrape.ly browser handles anti-bot fingerprints and Cloudflare Turnstile when a site needs a real browser.
  • Maintained scrapers for big sites. Amazon, Google Maps, LinkedIn and hundreds more have their own Actors, fixed by their publishers when the site changes. Browse the Store before you write a parser.
  • Schedules and deliveries without code. Run the same input daily and send the rows to Google Sheets, Slack, email or a webhook; see how to schedule and automate web scraping.
  • Costs you can cap. Every run takes a maximum charge and stops when it reaches it.

If you also write Python or Node.js, the same Actors are a client call away: see web scraping with Python and Node.js web scraping.

What to watch out for

  • Blocking. Fast requests from one IP get throttled or blocked. Keep delays, back off on 429, and move to rotating residential proxies when a site insists; our guide to rotating proxies for web scraping explains the options. On Scrape.ly the proxy is one input field.
  • Selectors break. Sites redesign. Check that every record has a title and a parsable price, and fail loudly instead of writing blank rows.
  • Locale and encoding bugs. Format numbers with Locale.ROOT, and decode bodies with the right charset. Both bugs produce files that look fine until someone imports them.
  • Personal data and terms. Scrape public pages, read the site's terms and robots.txt, and don't collect personal data you don't need; privacy laws such as the GDPR apply to it. Scrape.ly Store Actors only collect publicly available data shown to any visitor.

FAQ

Is Java good for web scraping?

Yes. Jsoup is one of the most pleasant HTML parsers in any language, the JDK ships a modern HTTP client, and virtual threads make concurrent fetching cheap. Java fits especially well when the scraper feeds an existing Java or Spring backend. Python has more scraping tutorials, but nothing a scraper needs is missing in Java.

What is the best Java library for web scraping?

Jsoup for static HTML: it fetches, parses and queries with CSS selectors. Add java.net.http.HttpClient for crawls that need retries and connection reuse, and Playwright for Java when pages render their content with JavaScript.

How do I scrape a JavaScript-rendered page in Java?

First look for a JSON API in the browser's Network tab and call it with HttpClient. If there isn't one, load the page in Playwright or Selenium, wait for the element you need (waitForSelector or WebDriverWait), then read it.

Java vs Python for web scraping: which is better?

Python has the bigger scraping ecosystem (BeautifulSoup, Scrapy) and shorter scripts. Java gives you static types, strong concurrency and an easy fit into JVM services. Choose the language your team already ships; for the heavy parts, such as proxies, browsers and scheduling, a platform like Scrape.ly works the same from both.

The language doesn't matter; what you scrape and how does. Collecting publicly available data is generally lawful in many places, but respect site terms, avoid overloading servers and handle personal data under privacy law. This isn't legal advice; our guide on whether web scraping is legal covers the details.

  • java
  • jsoup
  • web scraping
  • playwright
  • selenium
  • httpclient

About the author

The team behind Scrape.ly

Scrape.ly is the web data platform from TeraShift GmbH in Switzerland, powered by Evomi: ready-made scrapers in the Scrape.ly Store, a cloud runtime, an API with JavaScript and Python SDKs, proxies, a hosted anti-bot browser and an MCP server for AI agents.

//Keep reading\\

The internet is full of data.
Start owning yours.

Scrape.ly gives you the infrastructure, the tooling, and the team to extract, process, and operationalize web data at any scale. Setup takes minutes.

No credit card. No setup calls.