Crawlr Web Crawler
Build a polite concurrent web crawler with virtual threads, bounded concurrency, per-host rate limiting, robots.txt support, a race-free visited set and clean cancellation.
Revise first: Multithreading & ConcurrencyJava NetworkingException HandlingJava Collections FrameworkJava Testing (JUnit, Mockito)
Fetching one page is ten lines. Fetching ten thousand pages in parallel without crawling the same URL twice, without hammering one host, without leaking threads, and while stopping cleanly on Ctrl-C is a real concurrency exercise. Virtual threads make the blocking style cheap, but they do not remove the need to bound concurrency, throttle per host, and decide what cancellation means.
Target behaviour
$ crawlr --depth 3 --concurrency 20 --rps 5 --timeout 10s \
--same-host --out sitemap.json https://example.com
crawling https://example.com depth=3 concurrency=20 rps/host=5
200 / 14 links 82ms
200 /docs 31 links 120ms
404 /old-page 0 links 41ms
-- /admin blocked by robots.txt
...
done pages=842 ok=820 errors=22 skipped=17 unique_urls=1,204 elapsed=48s
wrote sitemap.json (842 pages, 3,118 edges)
^C -> cancelling: interrupting 20 fetches... flushed partial results to sitemap.jsonArchitecture
seed URLs
|
v
Crawler.submit(url, depth) --- visited.add(normalised)? --no--> drop
| yes (first time)
v
virtual thread per page (pending counter tracks the whole tree)
|
+--> Semaphore(concurrency) global bound on in-flight fetches
+--> RobotsCache.allowed(url) per host, fetched once, cached
+--> HostLimiter.await(host) min gap between requests to one host
+--> HttpClient.send(request) per-request timeout, deadline from CLI
+--> jsoup parse -> links -> normalise -> filter -> submit(child, depth+1)
|
v
ResultSink (ConcurrentLinkedQueue<PageResult>) -> sitemap.jsonWhy a thread per page but a semaphore on top: virtual threads are free to create, so the number of threads is not your limit. The Semaphore limits sockets and memory, the HostLimiter limits politeness, and the visited set limits total work. Three independent bounds, three different reasons.
The core of it
Start with the data and the dedupe key. Normalisation matters because https://Example.com/a#top and https://example.com/a are one page.
public record Task(URI url, int depth) {}
public record PageResult(URI url, int status, int links, long millis, String error) {}
static URI normalise(URI base, String href) {
try {
URI u = base.resolve(href.strip()).normalize();
if (!"http".equals(u.getScheme()) && !"https".equals(u.getScheme())) return null;
String path = u.getPath() == null || u.getPath().isEmpty() ? "/" : u.getPath();
return new URI(u.getScheme(), null, u.getHost().toLowerCase(), u.getPort(),
path, u.getQuery(), null); // null fragment drops #anchor
} catch (URISyntaxException | IllegalArgumentException | NullPointerException e) {
return null;
}
}The visited set must make "check and mark" one atomic step. Set.add on a concurrent set returns false if the element was already present, which is exactly that.
private final Set<URI> visited = ConcurrentHashMap.newKeySet();
private final AtomicInteger pending = new AtomicInteger();
private final CountDownLatch done = new CountDownLatch(1);
void submit(URI url, int depth) {
if (depth > maxDepth || !visited.add(url)) return;
pending.incrementAndGet(); // count BEFORE starting, never inside the task
pool.submit(() -> {
try { process(new Task(url, depth)); }
finally { if (pending.decrementAndGet() == 0) done.countDown(); }
});
}Per-host politeness: reserve the next slot under a lock and sleep outside it, so many threads waiting on one host do not serialise on the lock longer than needed.
final class HostLimiter {
private final Map<String, Long> nextFree = new HashMap<>();
private final long gapNanos;
HostLimiter(double rps) { this.gapNanos = (long) (1_000_000_000L / rps); }
void await(String host) throws InterruptedException {
long wait;
synchronized (this) {
long now = System.nanoTime();
long slot = Math.max(now, nextFree.getOrDefault(host, now));
nextFree.put(host, slot + gapNanos);
wait = slot - now;
}
if (wait > 0) TimeUnit.NANOSECONDS.sleep(wait);
}
}Fetching combines the semaphore, the limiter, a request timeout, and the cancellation signal. Interrupting a virtual thread blocked in HttpClient.send throws InterruptedException, which is your cancellation path.
void process(Task t) throws InterruptedException {
if (!robots.allowed(t.url())) { sink.add(new PageResult(t.url(), -1, 0, 0, "robots")); return; }
permits.acquire();
try {
limiter.await(t.url().getHost());
long start = System.nanoTime();
var req = HttpRequest.newBuilder(t.url()).timeout(Duration.ofSeconds(10))
.header("User-Agent", "crawlr/1.0").GET().build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
List<URI> links = extractLinks(t.url(), res.body());
sink.add(new PageResult(t.url(), res.statusCode(), links.size(), ms(start), null));
links.stream().filter(this::inScope).forEach(l -> submit(l, t.depth() + 1));
} catch (IOException e) {
sink.add(new PageResult(t.url(), 0, 0, 0, e.toString()));
} finally { permits.release(); }
}robots.txt is fetched once per host. Cache the Future in a ConcurrentHashMap with computeIfAbsent so 20 threads arriving at once cause one request, and treat a 404 as "allow all".
boolean allowed(URI u) throws InterruptedException {
String origin = u.getScheme() + "://" + u.getAuthority();
Rules rules = cache.computeIfAbsent(origin, o -> fetchRules(URI.create(o + "/robots.txt")));
return rules.allows(u.getPath());
}Do not hold a semaphore permit while waiting on the limiter for a long time if you can avoid it; in this version the permit is acquired first for simplicity. Cancellation is the last piece: the main thread awaits done with the overall deadline, and on timeout or Ctrl-C it calls pool.shutdownNow(), which interrupts every in-flight fetch.
Runtime.getRuntime().addShutdownHook(new Thread(() -> { pool.shutdownNow(); writer.flush(); }));
boolean finished = done.await(deadline.toSeconds(), TimeUnit.SECONDS);
if (!finished) pool.shutdownNow();Testing
Run a real local server with WireMock so tests cover real HTTP, redirects and timeouts without touching the internet.
@WireMockTest
class CrawlerTest {
@Test
void crawlsOnlyOnceAndHonoursRobots(WireMockRuntimeInfo wm) throws Exception {
stubFor(get("/robots.txt").willReturn(ok("User-agent: *\nDisallow: /private")));
stubFor(get("/").willReturn(ok("<a href='/a'>a</a><a href='/a#x'>a2</a><a href='/private'>p</a>")));
stubFor(get("/a").willReturn(ok("<a href='/'>home</a>")));
var results = new Crawler(Config.of(wm.getHttpBaseUrl(), 3, 4, 100.0)).run();
assertThat(results).extracting(r -> r.url().getPath()).containsExactlyInAnyOrder("/", "/a", "/private");
verify(1, getRequestedFor(urlEqualTo("/a")));
verify(0, getRequestedFor(urlEqualTo("/private")));
}
}Add a second test with a stub that delays 5 seconds, a 200 ms client timeout, and an assertion that the result carries a timeout error and the run still terminates.
Bugs you will hit
- Counting pending work inside the task. If the increment happens after
submitreturns, the counter can hit zero while children are about to be queued, anddonefires early with half the crawl missing. Increment before the task starts, as above. - Check-then-act on the visited set.
if (!visited.contains(u)) visited.add(u)lets two threads both pass and crawl the page twice. Use the return value ofadd. - Swallowing
InterruptedException. A barecatch (Exception e)in the worker eats the interrupt, andshutdownNowappears to do nothing. Rethrow or callThread.currentThread().interrupt(), and never catchInterruptedExceptioninto an error result silently. - Holding
synchronizedacrosssleepor I/O. Blocking inside a monitor serialises every thread and, on Java 21, pins the carrier thread. Reserve the slot inside the lock and sleep outside it, or useReentrantLock. - Unbounded crawl traps. Calendars and session ids generate infinite unique URLs. Cap total pages, strip tracking parameters in
normalise, and stop atmaxDepth.
Interview angle
- Why do you still need a Semaphore if virtual threads are cheap? Threads are no longer the scarce resource; sockets, memory per response, and the remote server are. The semaphore caps in-flight requests so you do not open 50,000 connections.
- How does cancellation work with blocking calls? Interrupting a virtual thread unblocks
HttpClient.sendwithInterruptedException. Cancellation is cooperative, so every blocking call must propagate the interrupt rather than swallow it. - What is the difference between
ExecutorService.shutdownandshutdownNow?shutdownstops accepting tasks and lets queued ones finish;shutdownNowalso interrupts running tasks and returns the ones never started. A crawler that must stop on Ctrl-C needs the second. - How would
StructuredTaskScopechange this design? Each page's children become subtasks of a scope, so a deadline or a failure cancels the whole subtree and no task outlives its parent. It is preview in Java 21, so most production code still uses an executor with an explicit pending counter.
Milestones
Done when
- Every URL is fetched at most once regardless of concurrency or fragment differences.
- No host receives more than the configured requests per second.
- Paths disallowed by
robots.txtare never requested. - A hanging server causes a recorded timeout error, never a stuck crawl.
- Ctrl-C terminates promptly and the output file is valid JSON.
- Running the tests 50 times in a loop produces no failures or leaked threads.
Stretch goals
- Honour
Crawl-delayandSitemap:entries from robots.txt. - Replace the executor and counter with
StructuredTaskScopeand compare the code. - Retry 429 and 5xx responses with exponential backoff and jitter, respecting
Retry-After. - Persist the frontier to disk so an interrupted crawl can resume.
- Expose Micrometer counters for fetched pages, errors and in-flight requests.