Concurrent URL Health Checker in Java 21
Use Java 21 virtual threads to build a concurrent URL health checker.
Introduction
30 Second Summary
Every time you open a webpage, your browser fires off dozens of network requests and waits for responses. Imagine doing that for hundreds of URLs at once to check which ones are healthy and which are broken.
In this project, you will build a command-line URL health checker in Java that fires off concurrent HTTP requests using virtual threads, then formats the results into a colorized report using modern type-safe patterns.
What You'll Build
A Java 21 application that checks the health status of a list of URLs concurrently, benchmarks platform threads vs virtual threads side-by-side, and prints a formatted report using pattern matching on sealed interfaces.
By the end of this project, you'll have:
- A working concurrent URL health checker that fetches multiple sites in parallel using virtual threads.
- Measurable benchmark results proving virtual threads outperform platform threads for I/O-bound work.
- Production-quality domain modeling using records, sealed interfaces, and exhaustive pattern matching.
- Secret Mission: Add a retry mechanism with exponential backoff for failed checks, and output results to a JSON file.
Are there any prerequisites?
You should be comfortable writing Java classes and familiar with basic object-oriented programming concepts like inheritance and interfaces. You should also understand what threads are conceptually, even if you have not worked with concurrency directly.
Set Up JDK 21
Your URL health checker relies on features that only exist in Java 21 or newer: virtual threads, records, sealed interfaces, and pattern matching with switch. These are all stable, production-ready language features that shipped with JDK 21, the previous Long-Term Support release.
In this step, you'll install JDK 21, verify that the compiler and runtime are accessible from your terminal, and create the project directory where all your source files will live.
In this step, get ready to:
- Install and configure JDK 21 on your system.
- Verify that java and javac are accessible from the terminal.
- Create the project directory with a healthcheck package folder.
Download and install JDK 21
JDK 21 is an LTS release from Oracle, free to use under the No-Fee Terms and Conditions license. You need it installed on your machine before you can compile or run any Java code.
- Open the Oracle JDK 21 downloads page in your browser.
- Select the JDK 21 tab and choose your operating system.
macOS
- Download the .dmg installer for ARM (Apple Silicon) or x64 (Intel).
- Double-click the downloaded .dmg file, then double-click the .pkg file inside to start the installer.
- Follow the prompts to complete installation.
Now set the JAVA_HOME environment variable. Open your shell profile (~/.zshrc for Zsh, which is the macOS default) and add these lines:
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH="$JAVA_HOME/bin:$PATH"
- Save the file and apply the changes by running:
source ~/.zshrc
Why set JAVA_HOME?
Many Java tools (build systems, IDEs, application servers) look for the JAVA_HOME environment variable to locate the JDK. Setting it now prevents cryptic errors later when those tools cannot find your Java installation.
Windows
- Download the Windows x64 Installer (.exe file).
- Double-click the downloaded .exe file to launch the installer.
- Follow the prompts and accept the default installation directory (C:\Program Files\Java\jdk-21).
- The installer automatically adds java and javac to your system PATH.
Now set the JAVA_HOME environment variable so tools can find your JDK installation:
- Press the Windows key, type Environment Variables, and select Edit the system environment variables.
- Click Environment Variables at the bottom of the dialog.
- Under System variables, click New.
- Set Variable name to JAVA_HOME and Variable value to C:\Program Files\Java\jdk-21.
- Click OK on all open dialogs to save.
Why set JAVA_HOME?
Many Java tools (build systems, IDEs, application servers) look for the JAVA_HOME environment variable to locate the JDK. Setting it now prevents cryptic errors later when those tools cannot find your Java installation.
Verify your Java installation
After installing JDK 21, you need to confirm that your terminal can find the correct version. Open a new terminal window so it picks up the updated environment variables.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Java version by running this command:
java --version
✔️ I see java 21 or higher
Your JDK is installed and ready to go. The output should start with something like java 21 followed by build details.
ⓧ I see an older version
Your system has an older Java version taking priority on the PATH. You need to fix this so java 21 is the default.
- On Windows: re-open Environment Variables, find the Path variable in System variables, and move the JDK 21 entry (C:\Program Files\Java\jdk-21\bin) to the top of the list.
- On macOS: confirm your shell profile sets JAVA_HOME to the JDK 21 path and that $JAVA_HOME/bin appears before other Java paths in PATH.
- Close and reopen your terminal, then run java --version again.
ⓧ Command not found
Java is not installed or not on your PATH.
- Go back to the Oracle JDK 21 downloads page and download the installer for your operating system.
- Run the installer and follow the prompts.
- After installation, close your terminal completely and open a fresh one.
- Run java --version again to confirm the install succeeded.
Create your project directory and test compilation
With Java confirmed, you need a dedicated folder for this project. You'll also create a healthcheck subdirectory for your package files and a quick test file to prove compilation works.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, the package directory, and move into the project by running these commands:
mkdir url-health-checker
cd url-health-checker
mkdir healthcheck
What is the healthcheck directory for?
Java uses directories to represent packages. All the production code in this project uses package healthcheck; at the top of each file. The healthcheck folder maps directly to that package name.
- Confirm the directory structure exists by running:
ls
You should see healthcheck listed in the output.
Now create a test file to verify that the Java compiler and runtime both work end-to-end.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor (or VS Code) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the url-health-checker folder, then click Open.
- Create a new file called Hello.java in the root of the url-health-checker folder.
- Paste the following code into Hello.java:
class Hello {
public static void main(String[] args) {
System.out.println("Hello, Java 21!");
}
}
What does this code do?
This is the simplest possible Java program. It defines a class with a main method (the entry point) that prints a line of text to the terminal. If this compiles and runs, your entire Java toolchain is working.
- Save Hello.java.
- Back in your terminal, make sure you are inside the url-health-checker folder.
- Compile and run the test file by running these commands:
javac Hello.java
java Hello
You should see Hello, Java 21! printed in the terminal.
Don't see the expected output?
- If you get a "javac is not recognized" error, your PATH is not set correctly. Revisit the environment variable setup above and open a fresh terminal.
- If you see a compilation error, check that you copied the code exactly. Capitalization matters in Java.
- Make sure you are running the commands from inside the url-health-checker folder where Hello.java lives.
Help me troubleshoot my Java compilation error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved Hello.java and seen the output in your terminal.
ⓧ I'd like to double check the full code
class Hello {
public static void main(String[] args) {
System.out.println("Hello, Java 21!");
}
}
Your Java toolchain is working and your project directory is ready. Next up, you'll build the domain model that represents health check results using sealed interfaces and records.
Model the Domain with Records and Sealed Interfaces
Your Java environment is locked in. Now it's time to design the data structures that will flow through your entire application.
When you check a URL's health, three things can happen: the site responds successfully, the site responds with an error code, or the site can't be reached at all. In traditional Java, you'd model this with class hierarchies and null checks scattered everywhere. Java 21's sealed interfaces and records let you model these three outcomes as a closed type system that the compiler enforces.
In this step, get ready to:
- Define a sealed interface with three record implementations for health check outcomes.
- Build a URL health checker class that uses Java's HttpClient to probe URLs.
- Compile and test the checker against a live URL to confirm it works.
Create the sealed result types
A sealed interface restricts which classes can implement it. The compiler knows the complete set of subtypes, which means pattern matching on the result is exhaustive. No stray subclass can sneak in at runtime.
- In Cursor, right-click the healthcheck folder in the Explorer sidebar and select New File.
- Name the file HealthCheckResult.java.
- Paste the following code into the new file:
package healthcheck;
import java.time.Instant;
public sealed interface HealthCheckResult permits
HealthCheckResult.Healthy,
HealthCheckResult.Unhealthy,
HealthCheckResult.Unreachable {
String url();
record Healthy(String url, int statusCode, long responseTimeMs) implements HealthCheckResult {}
record Unhealthy(String url, int statusCode, long responseTimeMs) implements HealthCheckResult {}
record Unreachable(String url, String errorMessage) implements HealthCheckResult {}
}
What does this code do?
The sealed keyword locks down who can implement this interface. Only Healthy, Unhealthy, and Unreachable are permitted.
Each record is a compact data class. The compiler generates the constructor, getters, equals(), hashCode(), and toString() automatically from the fields you declare in the parentheses.
The url() method in the interface ensures every result carries the URL it checked, regardless of outcome.
- Save HealthCheckResult.java.
- You won't see a change yet. The next substep creates the checker class that uses this interface.
Build the URL health checker class
The checker class takes a URL, fires an HTTP GET request, and returns one of the three result types based on what happens. Java's built-in HttpClient handles all the networking. It's thread-safe and manages its own connection pool internally.
- Right-click the healthcheck folder again and select New File.
- Name the file UrlHealthChecker.java.
- Paste the following code into the new file:
package healthcheck;
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 UrlHealthChecker {
private final HttpClient httpClient;
public UrlHealthChecker(HttpClient httpClient) {
this.httpClient = httpClient;
}
public HealthCheckResult check(String url) {
long startTime = System.nanoTime();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<Void> response = httpClient.send(
request,
HttpResponse.BodyHandlers.discarding()
);
long elapsed = (System.nanoTime() - startTime) / 1_000_000;
int statusCode = response.statusCode();
if (statusCode >= 200 && statusCode < 300) {
return new HealthCheckResult.Healthy(url, statusCode, elapsed);
} else {
return new HealthCheckResult.Unhealthy(url, statusCode, elapsed);
}
} catch (Exception e) {
return new HealthCheckResult.Unreachable(url, e.getMessage());
}
}
}
What does this code do?
- The constructor accepts an HttpClient instance. This lets you share one client across many checks (it manages connection pooling for you).
- The check() method builds an HTTP GET request with a 5-second timeout, sends it, and measures how long the round trip takes.
- BodyHandlers.discarding() tells Java to throw away the response body. You only care about the status code, not the HTML content.
- A 2xx status returns Healthy, any other code returns Unhealthy, and any exception (timeout, DNS failure) returns Unreachable.
- Save UrlHealthChecker.java.
- You won't see a change yet. The next substep compiles both files and runs a live test.
Compile and test with a single URL
Both files are written. Time to compile the entire package and fire a real HTTP request to prove the checker returns the correct result type.
- Compile both source files by running this command in your terminal:
javac healthcheck/HealthCheckResult.java healthcheck/UrlHealthChecker.java
- You should see no output. No output from javac means compilation succeeded.
Seeing a compilation error?
- Make sure both files are saved in the healthcheck folder, not in the project root.
- Check that the first line of each file says package healthcheck; exactly. The package declaration must match the folder name.
- Confirm you're running the javac command from the parent directory that contains the healthcheck folder, not from inside it.
Why won't my Java files compile?
Now create a quick test file to verify the checker works against a live URL.
- Create a new file called TestCheck.java inside the healthcheck folder.
- Paste the following test code into it:
package healthcheck;
import java.net.http.HttpClient;
public class TestCheck {
public static void main(String[] args) {
// Create a shared HttpClient instance
HttpClient client = HttpClient.newHttpClient();
UrlHealthChecker checker = new UrlHealthChecker(client);
// Test with a single URL
HealthCheckResult result = checker.check("https://www.google.com");
System.out.println("Result: " + result);
}
}
- Save TestCheck.java.
- Compile and run the test by running these commands:
javac healthcheck/*.java
java healthcheck.TestCheck
- You should see output like Result: Healthy[url=https://www.google.com, statusCode=200, responseTimeMs=...] with your actual response time in place of the dots.
Why does the output format itself so nicely?
Records generate a toString() method automatically. It prints the record name followed by all field values in brackets. No manual formatting needed.
Seeing Unreachable instead of Healthy?
- Check your internet connection. The test needs to reach https://www.google.com.
- If you're behind a corporate proxy or firewall, the request may be blocked. Try a different URL or check your network settings.
Help me debug why my health check returns Unreachable.
You can delete TestCheck.java now if you like. It served its purpose as a quick verification tool.
✔️ Awesome, I've got everything!
Great. Double check you've saved both HealthCheckResult.java and UrlHealthChecker.java in the healthcheck folder.
ⓧ I'd like to double check the full code
Here's the complete code for both files. Make sure yours matches exactly.
package healthcheck;
import java.time.Instant;
public sealed interface HealthCheckResult permits
HealthCheckResult.Healthy,
HealthCheckResult.Unhealthy,
HealthCheckResult.Unreachable {
String url();
record Healthy(String url, int statusCode, long responseTimeMs) implements HealthCheckResult {}
record Unhealthy(String url, int statusCode, long responseTimeMs) implements HealthCheckResult {}
record Unreachable(String url, String errorMessage) implements HealthCheckResult {}
}
package healthcheck;
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 UrlHealthChecker {
private final HttpClient httpClient;
public UrlHealthChecker(HttpClient httpClient) {
this.httpClient = httpClient;
}
public HealthCheckResult check(String url) {
long startTime = System.nanoTime();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<Void> response = httpClient.send(
request,
HttpResponse.BodyHandlers.discarding()
);
long elapsed = (System.nanoTime() - startTime) / 1_000_000;
int statusCode = response.statusCode();
if (statusCode >= 200 && statusCode < 300) {
return new HealthCheckResult.Healthy(url, statusCode, elapsed);
} else {
return new HealthCheckResult.Unhealthy(url, statusCode, elapsed);
}
} catch (Exception e) {
return new HealthCheckResult.Unreachable(url, e.getMessage());
}
}
}
Your domain model is locked in and proven. Next up, you'll put it to work by checking 20+ URLs concurrently with platform threads to establish a performance baseline.
Implement Platform Thread Concurrency
Your domain model is solid and your UrlHealthChecker works for a single URL. But checking 20 URLs one at a time would be painfully slow.
The traditional Java solution is a fixed thread pool. You create a pool of OS-managed threads and submit tasks to it. The pool limits how many tasks run simultaneously. In this step, you'll build the main application class and implement concurrent health checking using platform threads.
In this step, get ready to:
- Create the main application class with a list of 20 URLs to check.
- Implement platform thread concurrency with a fixed thread pool of 10.
- Run the application and observe the execution time.
Create the application class with URL targets
The main application class needs a list of URLs to check. You'll include a mix of sites that should be healthy, endpoints that return error codes, and a completely fake domain that will be unreachable.
- Create a new file called HealthCheckApp.java inside your healthcheck folder.
- Add the imports, class declaration, ANSI color constants, and URL list by pasting this code:
package healthcheck;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class HealthCheckApp {
// ANSI escape codes for colored terminal output
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_RESET = "\u001B[0m";
// Mix of valid sites, error-returning endpoints, and a fake domain
private static final List<String> URLS = List.of(
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.amazon.com",
"https://www.wikipedia.org",
"https://www.reddit.com",
"https://www.twitter.com",
"https://www.linkedin.com",
"https://www.apple.com",
"https://www.microsoft.com",
"https://www.oracle.com",
"https://www.mozilla.org",
"https://www.cloudflare.com",
"https://www.digitalocean.com",
"https://www.heroku.com",
"https://www.netlify.com",
"https://www.vercel.com",
"https://httpstat.us/500",
"https://httpstat.us/404",
"https://this-url-does-not-exist-xyz.com"
);
}
What does this code do?
The ANSI constants define color codes that Windows Terminal understands. You'll use them later to print healthy results in green, unhealthy in yellow, and unreachable in red.
The URLS list uses List.of() to create an immutable list of 20 targets. The last three entries are intentionally problematic. httpstat.us/500 returns a 500 error, httpstat.us/404 returns a 404, and the fake domain will throw a connection exception.
Now you need the main method that builds the HTTP client and kicks off the benchmark.
- Inside the HealthCheckApp class (below the URLS list), add the main method by pasting this code:
public static void main(String[] args) {
System.out.println("=== URL Health Checker - Java 21 Virtual Threads ===\n");
// Build a shared HttpClient with a 5-second connect timeout
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
UrlHealthChecker checker = new UrlHealthChecker(httpClient);
// Benchmark platform threads
System.out.println("--- Platform Threads (Fixed Pool of 10) ---");
long platformStart = System.nanoTime();
List<HealthCheckResult> platformResults = checkWithPlatformThreads(checker);
long platformTime = (System.nanoTime() - platformStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", platformTime);
httpClient.close();
}
What does this code do?
The HttpClient is built once and shared across all threads. It manages its own connection pool internally and is thread-safe.
The benchmark uses System.nanoTime() before and after the check to measure total wall-clock time. Dividing by 1_000_000 converts nanoseconds to milliseconds. The httpClient.close() call releases the client's resources when done.
Implement the platform thread method
A fixed thread pool creates a set number of OS threads and reuses them. When all 10 threads are busy, the remaining tasks wait in a queue until a thread becomes free. This caps your concurrency at 10 simultaneous checks.
- Below the main method (but still inside the class), add the checkWithPlatformThreads() method by pasting this code:
private static List<HealthCheckResult> checkWithPlatformThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
// Create a fixed pool of 10 OS threads
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
// Submit all 20 URL checks as tasks
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
// Collect results as each future completes
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
What does this code do?
Executors.newFixedThreadPool(10) creates a thread pool that reuses a fixed number of threads operating off a shared unbounded queue. Only 10 URLs can be checked simultaneously. The other 10 tasks wait.
The try-with-resources block is possible because ExecutorService implements AutoCloseable since JDK 21. Calling close() waits for all submitted tasks to terminate before continuing.
Each Future represents a pending result. Calling future.get() blocks until that particular check finishes and returns its HealthCheckResult.
Why a fixed pool of 10?
Platform threads are expensive. Each one maps 1:1 to an OS thread and consumes around 1 MB of stack memory. Production apps typically cap their pools at a modest size (10-50) to avoid overwhelming the system.
With 20 URLs and 10 threads, the pool has to process them in two batches. This is the bottleneck you'll measure and then eliminate with virtual threads in the next step.
- Save HealthCheckApp.java.
Seeing a red squiggly under checkWithPlatformThreads?
If you added the main method before pasting the checkWithPlatformThreads() method, your editor will show an error because the method doesn't exist yet. This resolves once you paste the method below.
Make sure both methods are inside the class braces, and that you have a final closing } for the class itself.
help me fix a compilation error in HealthCheckApp.java
✔️ Awesome, I've got everything!
Great. Double check that you've saved HealthCheckApp.java and that the file has the closing } brace for the class.
ⓧ I'd like to double check the full code
package healthcheck;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class HealthCheckApp {
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_RESET = "\u001B[0m";
private static final List<String> URLS = List.of(
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.amazon.com",
"https://www.wikipedia.org",
"https://www.reddit.com",
"https://www.twitter.com",
"https://www.linkedin.com",
"https://www.apple.com",
"https://www.microsoft.com",
"https://www.oracle.com",
"https://www.mozilla.org",
"https://www.cloudflare.com",
"https://www.digitalocean.com",
"https://www.heroku.com",
"https://www.netlify.com",
"https://www.vercel.com",
"https://httpstat.us/500",
"https://httpstat.us/404",
"https://this-url-does-not-exist-xyz.com"
);
public static void main(String[] args) {
System.out.println("=== URL Health Checker - Java 21 Virtual Threads ===\n");
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
UrlHealthChecker checker = new UrlHealthChecker(httpClient);
// Benchmark platform threads
System.out.println("--- Platform Threads (Fixed Pool of 10) ---");
long platformStart = System.nanoTime();
List<HealthCheckResult> platformResults = checkWithPlatformThreads(checker);
long platformTime = (System.nanoTime() - platformStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", platformTime);
httpClient.close();
}
private static List<HealthCheckResult> checkWithPlatformThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
}
Compile and run the application
Time to see platform threads in action. You'll compile all three files together and run the app.
- Compile all Java files by running this command from your project root (the folder that contains the healthcheck folder):
javac healthcheck/*.java
- Run the application by executing this command:
java healthcheck.HealthCheckApp
You should see the banner print, followed by a pause while the URLs are checked, then the total execution time in milliseconds.
What should I see?
Your output will look something like this (the exact time depends on your network):
=== URL Health Checker - Java 21 Virtual Threads ===
--- Platform Threads (Fixed Pool of 10) ---
Completed in: 4523 ms
The number will vary, but expect something in the 2000-8000 ms range. The pool of 10 threads processes the 20 URLs in roughly two batches, so the total time is about twice the slowest single request.
Seeing a compilation error?
Make sure you're running javac from the parent folder that contains the healthcheck directory, not from inside it. The package healthcheck; declaration requires this folder structure.
Also check that the closing brace } for the class is present at the very end of the file. Every method and the class itself needs its own closing brace.
help me fix this Java compilation error
You now have a baseline measurement. Your 20 URLs were processed with a maximum of 10 running at any given time. In the next step, you'll swap in virtual threads and watch that constraint disappear entirely.
Implement Virtual Thread Concurrency
Your platform thread benchmark is working, but it reveals a limitation. With a fixed pool of 10 threads, only 10 URLs can be checked simultaneously.
Virtual threads solve this by letting every single URL get its own lightweight thread. Instead of waiting in line for a pooled thread to become free, all URLs fire off at once. In this step, you'll add the virtual thread implementation, run both approaches side-by-side, and see the difference in real numbers.
In this step, get ready to:
- Add a virtual thread concurrency method to your app.
- Benchmark both threading models side-by-side with a printed comparison.
- Scale the URL list to observe how the performance gap widens.
Add the virtual thread executor method
Virtual threads use the same ExecutorService interface you already know. The only difference is how the executor is created.
- In HealthCheckApp.java, scroll to the bottom of the file, after the closing brace of checkWithPlatformThreads().
- Add the following method:
private static List<HealthCheckResult> checkWithVirtualThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
What does this code do?
This method is structurally identical to checkWithPlatformThreads() with one key change: Executors.newVirtualThreadPerTaskExecutor() replaces Executors.newFixedThreadPool(10).
Instead of limiting you to 10 concurrent threads, this executor creates a new virtual thread for every submitted task. All 20 URLs fire off simultaneously rather than waiting in batches of 10.
Why virtual threads instead of a bigger thread pool?
You could increase the platform thread pool to 20 or 50, but each platform thread maps 1:1 to an operating system thread. OS threads consume about 1 MB of memory each, so scaling to hundreds or thousands gets expensive fast.
Virtual threads are managed by the JVM and cost only a few kilobytes each. You can spin up thousands without running into memory limits. The blocking send() call inside HttpClient pauses only the virtual thread while the OS thread moves on to other work.
Update main to benchmark both approaches
Now you need main() to call the new method, time it, and print a comparison.
- In HealthCheckApp.java, find this line inside main():
System.out.printf("Completed in: %d ms%n%n", platformTime);
- Add the following code between that line and httpClient.close();:
// Benchmark virtual threads
System.out.println("--- Virtual Threads (One per Task) ---");
long virtualStart = System.nanoTime();
List<HealthCheckResult> virtualResults = checkWithVirtualThreads(checker);
long virtualTime = (System.nanoTime() - virtualStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", virtualTime);
// Comparison
System.out.println("=== Performance Comparison ===");
System.out.printf("Platform Threads: %d ms%n", platformTime);
System.out.printf("Virtual Threads: %d ms%n", virtualTime);
if (virtualTime < platformTime) {
System.out.printf("Virtual threads were %.1fx faster!%n%n",
(double) platformTime / virtualTime);
}
What does this code do?
- The virtual thread benchmark block mirrors the platform thread block: start timer, run checks, stop timer, print elapsed time.
- The comparison section prints both times and calculates a speedup factor when virtual threads win.
- The if guard means the speedup message only prints when virtual threads are actually faster (network variability could occasionally reverse the result on very small lists).
- Save HealthCheckApp.java.
- Compile and run the application by running these commands in your terminal:
javac healthcheck/*.java
java healthcheck.HealthCheckApp
You should see output showing both benchmark times and a performance comparison. Virtual threads typically finish 1.5x to 3x faster because all 20 URLs are checked concurrently instead of in batches of 10.
Not seeing a speedup?
Network conditions vary. If you're on a very fast connection where each request finishes in under 50ms, the fixed pool of 10 can cycle through 20 URLs almost as quickly. Try running the program a few more times to see the pattern emerge.
If you see a compilation error, make sure your new checkWithVirtualThreads() method is placed inside the HealthCheckApp class (before the final closing brace).
help me debug my HealthCheckApp compilation error
Scale the URL list to widen the performance gap
With 20 URLs, the difference is visible but modest. The real power of virtual threads becomes clear when you scale up. The platform thread pool stays bottlenecked at 10 concurrent checks while virtual threads handle everything at once.
- In HealthCheckApp.java, find the URLS list.
- Duplicate existing entries to bring the total to 100 or more. Repeating the same URLs is fine for benchmarking purposes.
- Save the file, then recompile and run:
javac healthcheck/*.java
java healthcheck.HealthCheckApp
With 100+ URLs, the performance gap grows dramatically. Platform threads process 10 at a time while virtual threads fire all 100+ simultaneously.
Why does the gap widen?
Platform threads are bottlenecked by pool size. With 100 URLs and 10 threads, the pool must cycle through 10 batches sequentially. Virtual threads launch all 100 at once because each virtual thread costs only a few kilobytes of memory.
Oracle's documentation states that virtual threads provide scale (higher throughput), not speed (lower latency). Each individual request takes the same time. The gain comes from overlapping all the waiting.
- After observing the results, revert the URLS list back to the original 20 entries. The next step needs a manageable output size for the formatted report.
- Save HealthCheckApp.java.
Keep the experiment around
If you want to re-run the 100+ URL benchmark later, save a copy of the expanded list in a comment block at the bottom of the file. You can uncomment it anytime to demo the scaling difference.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file with the 20-URL list restored.
ⓧ I'd like to double check the full code
package healthcheck;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class HealthCheckApp {
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_RESET = "\u001B[0m";
private static final List<String> URLS = List.of(
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.amazon.com",
"https://www.wikipedia.org",
"https://www.reddit.com",
"https://www.twitter.com",
"https://www.linkedin.com",
"https://www.apple.com",
"https://www.microsoft.com",
"https://www.oracle.com",
"https://www.mozilla.org",
"https://www.cloudflare.com",
"https://www.digitalocean.com",
"https://www.heroku.com",
"https://www.netlify.com",
"https://www.vercel.com",
"https://httpstat.us/500",
"https://httpstat.us/404",
"https://this-url-does-not-exist-xyz.com"
);
public static void main(String[] args) {
System.out.println("=== URL Health Checker - Java 21 Virtual Threads ===\n");
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
UrlHealthChecker checker = new UrlHealthChecker(httpClient);
// Benchmark platform threads
System.out.println("--- Platform Threads (Fixed Pool of 10) ---");
long platformStart = System.nanoTime();
List<HealthCheckResult> platformResults = checkWithPlatformThreads(checker);
long platformTime = (System.nanoTime() - platformStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", platformTime);
// Benchmark virtual threads
System.out.println("--- Virtual Threads (One per Task) ---");
long virtualStart = System.nanoTime();
List<HealthCheckResult> virtualResults = checkWithVirtualThreads(checker);
long virtualTime = (System.nanoTime() - virtualStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", virtualTime);
// Comparison
System.out.println("=== Performance Comparison ===");
System.out.printf("Platform Threads: %d ms%n", platformTime);
System.out.printf("Virtual Threads: %d ms%n", virtualTime);
if (virtualTime < platformTime) {
System.out.printf("Virtual threads were %.1fx faster!%n%n",
(double) platformTime / virtualTime);
}
httpClient.close();
}
private static List<HealthCheckResult> checkWithPlatformThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
private static List<HealthCheckResult> checkWithVirtualThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
}
You now have hard evidence that virtual threads outperform fixed thread pools for I/O-bound work. Next up, you'll add pattern matching to turn the raw results into a colorized, formatted report.
Pattern Match Results into a Formatted Report
Your health checker now fetches URLs concurrently and proves virtual threads are faster. But the raw results are sitting in a List<HealthCheckResult> with no human-readable output.
This is where Java 21's pattern matching on sealed types shines. Because HealthCheckResult is a sealed interface with exactly three record implementations, the compiler can verify that your switch expression handles every possible case. You get exhaustive, type-safe branching with zero instanceof checks and zero casting.
In this step, get ready to:
- Implement a printReport() method using switch expressions with record pattern destructuring.
- Add summary statistics that aggregate results using the counters from the switch cases.
- Run the complete application end-to-end with the benchmark comparison and colorized report.
Implement the pattern matching switch expression
A switch expression with record patterns lets you destructure each record variant directly in the case label. You pull out the fields (URL, status code, response time) without any casting or getter calls.
- In HealthCheckApp.java, add the following printReport() method after the checkWithVirtualThreads() method:
private static void printReport(List<HealthCheckResult> results) {
// Counters for summary statistics
int healthy = 0, unhealthy = 0, unreachable = 0;
long totalResponseTime = 0;
long fastest = Long.MAX_VALUE;
long slowest = 0;
String fastestUrl = "";
String slowestUrl = "";
for (HealthCheckResult result : results) {
// Pattern match on the sealed interface - compiler verifies all cases
String line = switch (result) {
case HealthCheckResult.Healthy(var url, var code, var time) -> {
healthy++;
totalResponseTime += time;
if (time < fastest) { fastest = time; fastestUrl = url; }
if (time > slowest) { slowest = time; slowestUrl = url; }
yield String.format("%s[HEALTHY]%s %s - %d (%d ms)",
ANSI_GREEN, ANSI_RESET, url, code, time);
}
case HealthCheckResult.Unhealthy(var url, var code, var time) -> {
unhealthy++;
totalResponseTime += time;
if (time < fastest) { fastest = time; fastestUrl = url; }
if (time > slowest) { slowest = time; slowestUrl = url; }
yield String.format("%s[UNHEALTHY]%s %s - %d (%d ms)",
ANSI_YELLOW, ANSI_RESET, url, code, time);
}
case HealthCheckResult.Unreachable(var url, var msg) -> {
unreachable++;
yield String.format("%s[UNREACHABLE]%s %s - %s",
ANSI_RED, ANSI_RESET, url, msg);
}
};
System.out.println(line);
}
}
What does this code do?
The switch (result) expression matches against all three record types that implement HealthCheckResult. Because the interface is sealed, the compiler knows these three cases are exhaustive. No default branch is needed.
Each case destructures the record directly. For example, case HealthCheckResult.Healthy(var url, var code, var time) extracts all three fields from the Healthy record into local variables. The yield keyword returns a formatted string from the block-style case arm.
The ANSI color codes (ANSI_GREEN, ANSI_YELLOW, ANSI_RED) wrap the status label so each result type prints in a distinct color in your terminal.
Why no default case?
With traditional instanceof checks, you always need an else branch for safety. With sealed interfaces and pattern matching, the compiler proves at compile time that all variants are covered. If you later add a fourth record type to HealthCheckResult, this switch will refuse to compile until you handle it.
Add summary statistics
The counters and timing variables you declared at the top of printReport() are now populated by the switch cases. Time to print a summary that gives you a quick overview of your URL fleet's health.
- In HealthCheckApp.java, find the closing brace of the for loop inside printReport(). Add the following summary code between the end of the for loop and the method's closing brace:
int total = results.size();
int respondedCount = healthy + unhealthy;
long avgTime = respondedCount > 0 ? totalResponseTime / respondedCount : 0;
System.out.println("\n=== Summary ===");
System.out.printf("Total: %d | Healthy: %d | Unhealthy: %d | Unreachable: %d%n",
total, healthy, unhealthy, unreachable);
System.out.printf("Average response time: %d ms%n", avgTime);
if (respondedCount > 0) {
System.out.printf("Fastest: %s (%d ms)%n", fastestUrl, fastest);
System.out.printf("Slowest: %s (%d ms)%n", slowestUrl, slowest);
}
What does this code do?
The summary calculates respondedCount as the sum of healthy and unhealthy results. Unreachable URLs never returned a response, so they're excluded from the average response time calculation.
The fastest and slowest tracking variables were updated inside the Healthy and Unhealthy switch arms. The conditional guard if (respondedCount > 0) prevents printing fastest/slowest when every URL was unreachable.
Call the report from main and run the full application
The printReport() method is ready but main() doesn't call it yet. You need to wire it in so the report prints after the benchmark comparison.
- In HealthCheckApp.java, find this line inside main():
httpClient.close();
- Add these lines directly above httpClient.close();:
// Print detailed report using pattern matching
System.out.println("=== Health Check Report ===\n");
printReport(virtualResults);
What does this code do?
This passes the virtualResults list (produced by the virtual threads benchmark) into your new report method. The report prints after the performance comparison, giving you a complete picture: speed benchmarks first, then detailed per-URL results.
- Save HealthCheckApp.java.
- Compile the project by running this command:
javac healthcheck/*.java
- Run the full application by running:
java healthcheck.HealthCheckApp
- You should see the benchmark comparison followed by a colorized health report. Green [HEALTHY] lines for successful URLs, yellow [UNHEALTHY] lines for non-2xx responses, and red [UNREACHABLE] lines for failed connections. A summary section at the bottom shows totals, average response time, and your fastest and slowest URLs.
Seeing a compilation error?
If you see an error about the switch expression not covering all cases, check that your case labels use the fully qualified names: HealthCheckResult.Healthy, HealthCheckResult.Unhealthy, and HealthCheckResult.Unreachable.
If you see an error about variables used inside switch arms needing to be effectively final, ensure that healthy, unhealthy, and unreachable are declared as local variables inside the method (not as lambda captures).
Help me debug my pattern matching switch expression.
✔️ Awesome, I've got everything!
Great. Double check you've saved HealthCheckApp.java and the application compiles cleanly.
ⓧ I'd like to double check the full code
package healthcheck;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class HealthCheckApp {
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_RESET = "\u001B[0m";
private static final List<String> URLS = List.of(
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.amazon.com",
"https://www.wikipedia.org",
"https://www.reddit.com",
"https://www.twitter.com",
"https://www.linkedin.com",
"https://www.apple.com",
"https://www.microsoft.com",
"https://www.oracle.com",
"https://www.mozilla.org",
"https://www.cloudflare.com",
"https://www.digitalocean.com",
"https://www.heroku.com",
"https://www.netlify.com",
"https://www.vercel.com",
"https://httpstat.us/500",
"https://httpstat.us/404",
"https://this-url-does-not-exist-xyz.com"
);
public static void main(String[] args) {
System.out.println("=== URL Health Checker - Java 21 Virtual Threads ===\n");
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
UrlHealthChecker checker = new UrlHealthChecker(httpClient);
// Benchmark platform threads
System.out.println("--- Platform Threads (Fixed Pool of 10) ---");
long platformStart = System.nanoTime();
List<HealthCheckResult> platformResults = checkWithPlatformThreads(checker);
long platformTime = (System.nanoTime() - platformStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", platformTime);
// Benchmark virtual threads
System.out.println("--- Virtual Threads (One per Task) ---");
long virtualStart = System.nanoTime();
List<HealthCheckResult> virtualResults = checkWithVirtualThreads(checker);
long virtualTime = (System.nanoTime() - virtualStart) / 1_000_000;
System.out.printf("Completed in: %d ms%n%n", virtualTime);
// Comparison
System.out.println("=== Performance Comparison ===");
System.out.printf("Platform Threads: %d ms%n", platformTime);
System.out.printf("Virtual Threads: %d ms%n", virtualTime);
if (virtualTime < platformTime) {
System.out.printf("Virtual threads were %.1fx faster!%n%n",
(double) platformTime / virtualTime);
}
// Print detailed report using pattern matching
System.out.println("=== Health Check Report ===\n");
printReport(virtualResults);
httpClient.close();
}
private static List<HealthCheckResult> checkWithPlatformThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
private static List<HealthCheckResult> checkWithVirtualThreads(UrlHealthChecker checker) {
List<HealthCheckResult> results = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<HealthCheckResult>> futures = URLS.stream()
.map(url -> executor.submit(() -> checker.check(url)))
.toList();
for (Future<HealthCheckResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
// Should not happen as checker handles exceptions internally
}
}
}
return results;
}
private static void printReport(List<HealthCheckResult> results) {
int healthy = 0, unhealthy = 0, unreachable = 0;
long totalResponseTime = 0;
long fastest = Long.MAX_VALUE;
long slowest = 0;
String fastestUrl = "";
String slowestUrl = "";
for (HealthCheckResult result : results) {
String line = switch (result) {
case HealthCheckResult.Healthy(var url, var code, var time) -> {
healthy++;
totalResponseTime += time;
if (time < fastest) { fastest = time; fastestUrl = url; }
if (time > slowest) { slowest = time; slowestUrl = url; }
yield String.format("%s[HEALTHY]%s %s - %d (%d ms)",
ANSI_GREEN, ANSI_RESET, url, code, time);
}
case HealthCheckResult.Unhealthy(var url, var code, var time) -> {
unhealthy++;
totalResponseTime += time;
if (time < fastest) { fastest = time; fastestUrl = url; }
if (time > slowest) { slowest = time; slowestUrl = url; }
yield String.format("%s[UNHEALTHY]%s %s - %d (%d ms)",
ANSI_YELLOW, ANSI_RESET, url, code, time);
}
case HealthCheckResult.Unreachable(var url, var msg) -> {
unreachable++;
yield String.format("%s[UNREACHABLE]%s %s - %s",
ANSI_RED, ANSI_RESET, url, msg);
}
};
System.out.println(line);
}
int total = results.size();
int respondedCount = healthy + unhealthy;
long avgTime = respondedCount > 0 ? totalResponseTime / respondedCount : 0;
System.out.println("\n=== Summary ===");
System.out.printf("Total: %d | Healthy: %d | Unhealthy: %d | Unreachable: %d%n",
total, healthy, unhealthy, unreachable);
System.out.printf("Average response time: %d ms%n", avgTime);
if (respondedCount > 0) {
System.out.printf("Fastest: %s (%d ms)%n", fastestUrl, fastest);
System.out.printf("Slowest: %s (%d ms)%n", slowestUrl, slowest);
}
}
}
Your URL health checker is complete. You've built a full application that benchmarks two concurrency models side-by-side, then presents a colorized report using exhaustive pattern matching. Next up, push the project further with a Secret Mission that adds retry logic and JSON export.
Secret mission
Retry with Exponential Backoff + JSON Export
Your health checker treats every failure as final. In production, network hiccups are temporary. Can you add smart retries that back off exponentially, then export a full JSON report of every result?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- JDK 21 (installed via Windows MSI installer).
- Project folder on your Desktop containing the healthcheck directory, compiled .class files, and health-report.json.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the health checker further.
- JDK 21 stays installed and available for any Java project.
- Your project folder remains on the Desktop, ready for future edits.
- The health-report.json file will be overwritten each time you run the app.
Pause - I'll come back to this later
There are no running processes to stop. Your project files sit quietly on disk and consume minimal storage.
- No background services are running from this project.
- Keep JDK 21 installed so you can pick up where you left off.
- Your source files and health-report.json stay available for when you return.
Delete - I don't want to use this again
Remove all project resources and uninstall JDK 21 to start fresh.
Delete the project folder
Remove your project folder from the Desktop (the folder containing the healthcheck directory).
macOS
- Delete the project folder by running this command (replace the folder name with yours):
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
Windows
- Delete the project folder by running this command (replace the folder name with yours):
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]
Uninstall JDK 21
- Press the Windows key and type Add or remove programs. Press Enter.
- Search for Java in the app list.
- Click the three dots next to Java(TM) SE Development Kit 21 and select Uninstall.
- Follow the prompts to complete the uninstall.
- Confirm JDK is removed by opening a new terminal and running:
java --version
You should see an error indicating the command is not recognized.
Nice Work!
Nice Work!
You just built a concurrent URL health checker using Java 21's most powerful features. From sealed types to virtual threads to pattern matching, you've written modern, idiomatic Java that scales.
Here's what you accomplished:
- Built a type-safe domain model with sealed interfaces and records that eliminates invalid states at compile time.
- Used virtual threads to achieve massive I/O concurrency with simple, readable blocking code and proved the performance gain with real benchmarks.
- Applied exhaustive pattern matching with switch expressions to destructure records and handle every result variant cleanly.
- Added retry logic with exponential backoff and JSON export, leveraging virtual threads' cheap blocking for resilient health checks.
Ready to quiz yourself?