Multiple Requests Using HTTP/1.1 VS HTTP/2

The Java HTTP Client supports both HTTP/1.1 and HTTP/2. By default the client will send requests using HTTP/2. Requests sent to servers that do not yet support HTTP/2 will automatically be downgraded to HTTP/1.1. Here's a summary of the major improvements that HTTP/2 brings:

  • Header Compression. HTTP/2 uses HPACK compression, which reduces overhead.
  • Single Connection to the server, reduces the number of round trips needed to set up multiple TCP connections.
  • Multiplexing. Multiple requests are allowed at the same time, on the same connection.
  • Server Push. Additional future needed resources can be sent to a client.
  • Binary format. More compact.

We will looking at Single Connection and Multiplexing later on.

Since HTTP/2 is the default preferred protocol, and the implementation seamlessly fallbacks to HTTP/1.1 where necessary, then the Java HTTP Client is well positioned for the future, when HTTP/2 is more widely deployed.

When using a browser to loading a web page, several requests are sent behind the scenes. First request is sent to retrieve the main HTML of the page, and then follows by several requests to retrieve the resources needed by the HTML (images, js and css files, etc). Behind the scene, several TCP connections are created to support the parallel requests.

Each browser has their own limit for maximum connections per host name. As example, Firefox stores that number in this setting (you find it in about:config): network.http.max-persistent-connections-per-server by default is 6.

Firefox Http Max Connections

Firefox Http Max Connections

And as As far as I know, chrome hard-coded the limit to 6.

In this article, I'll create a similar Java program to load a web page. A request is first sent to retrieve the HTML main page. Then, we parse the result, and for every image in the document, a request is submitted in parallel using an executor with a limited number of threads (6 based on my two favorite browsers):

Requests Using HTTP/1.1

JEP321Http11Client.java
package com.dariawan.jdk11;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;

public class JEP321Http11Client {

    static ExecutorService executor = Executors.newFixedThreadPool(6, (Runnable r) -> {
        return new Thread(r);
    });

    public static void main(String[] args) throws Exception {
        System.out.println("Running HTTP/1.1 example...");
        try {
            HttpClient httpClient = HttpClient.newBuilder()
                    .version(Version.HTTP_1_1)
                    .build();

            long start = System.currentTimeMillis();

            HttpRequest pageRequest = HttpRequest.newBuilder()
                    .uri(URI.create("https://http1.golang.org/gophertiles"))
                    .build();

            HttpResponse<String> pageResponse = httpClient.send(pageRequest, BodyHandlers.ofString());

            System.out.println("Page response status code: " + pageResponse.statusCode());
            System.out.println("Page response headers: " + pageResponse.headers());
            String responseBody = pageResponse.body();
            System.out.println(responseBody);

            List<Future<?>> futures = new ArrayList<>();
            AtomicInteger atomicInt = new AtomicInteger(1);

            Document doc = Jsoup.parse(responseBody);
            Elements imgs = doc.select("img[width=32]"); // img with width=32

            // Send request on a separate thread for each image in the page, 
            imgs.forEach(img -> {
                var image = img.attr("src");
                Future<?> imgFuture = executor.submit(() -> {
                    HttpRequest imgRequest = HttpRequest.newBuilder()
                            .uri(URI.create("https://http1.golang.org" + image))
                            .build();
                    try {
                        HttpResponse<String> imageResponse = httpClient.send(imgRequest, BodyHandlers.ofString());
                        System.out.println("[" + atomicInt.getAndIncrement() + "] Loaded " + image + ", status code: " + imageResponse.statusCode());
                    } catch (IOException | InterruptedException ex) {
                        System.out.println("Error loading image " + image + ": " + ex.getMessage());
                    }
                });
                futures.add(imgFuture);
                System.out.println("Adding future for image " + image);
            });

            // Wait for image loads to be completed
            futures.forEach(f -> {
                try {
                    f.get();
                } catch (InterruptedException | ExecutionException ex) {
                    System.out.println("Exception during loading images: " + ex.getMessage());
                }

            });

            long end = System.currentTimeMillis();
            System.out.println("Total load time: " + (end - start) + " ms");
            System.out.println(atomicInt.get() - 1 + " images loaded");
        } finally {
            executor.shutdown();
        }
    }
}
                    

We are using demo page from https://http1.golang.org/gophertiles. Parsing of the HTML and listing all images is done using jsoup library. And we are using AtomicInteger that provides us with a thread-safe int variable which can be read and written atomically.

To monitor TCP connections in Windows environment, I'm using TCPView v3.05. TCPView is a Windows program that will show you detailed listings of all TCP and UDP endpoints on your system, including the local and remote addresses and state of TCP connections< as appeared in below screenshot:

TCPView HTTP/1.1

TCPView HTTP/1.1

Requests Using HTTP/2

Now, we switch to HTTP/2. We change our HttpClient to version(Version.HTTP_2) and our target to https://http2.golang.org/gophertiles.

JEP321Http2Client.java
package com.dariawan.jdk11;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;

public class JEP321Http2Client {

    static ExecutorService executor = Executors.newFixedThreadPool(6, (Runnable r) -> {
        return new Thread(r);
    });

    public static void main(String[] args) throws Exception {
        System.out.println("Running HTTP/2 example...");
        try {
            HttpClient httpClient = HttpClient.newBuilder()
                    .version(Version.HTTP_2)
                    .build();

            long start = System.currentTimeMillis();

            HttpRequest pageRequest = HttpRequest.newBuilder()
                    .uri(URI.create("https://http2.golang.org/gophertiles"))
                    .build();

            HttpResponse<String> pageResponse = httpClient.send(pageRequest, BodyHandlers.ofString());

            System.out.println("Page response status code: " + pageResponse.statusCode());
            System.out.println("Page response headers: " + pageResponse.headers());
            String responseBody = pageResponse.body();
            System.out.println(responseBody);

            List<Future<?>> futures = new ArrayList<>();
            AtomicInteger atomicInt = new AtomicInteger(1);

            Document doc = Jsoup.parse(responseBody);
            Elements imgs = doc.select("img[width=32]"); // img with width=32

            // Send request on a separate thread for each image in the page, 
            imgs.forEach(img -> {
                var image = img.attr("src");
                Future<?> imgFuture = executor.submit(() -> {
                    HttpRequest imgRequest = HttpRequest.newBuilder()
                            .uri(URI.create("https://http2.golang.org" + image))
                            .build();
                    try {
                        HttpResponse<String> imageResponse = httpClient.send(imgRequest, BodyHandlers.ofString());
                        System.out.println("[" + atomicInt.getAndIncrement() + "] Loaded " + image + ", status code: " + imageResponse.statusCode());
                    } catch (IOException | InterruptedException ex) {
                        System.out.println("Error loading image " + image + ": " + ex.getMessage());
                    }
                });
                futures.add(imgFuture);
                System.out.println("Adding future for image " + image);
            });

            // Wait for image loads to be completed
            futures.forEach(f -> {
                try {
                    f.get();
                } catch (InterruptedException | ExecutionException ex) {
                    System.out.println("Exception during loading images: " + ex.getMessage());
                }
            });

            long end = System.currentTimeMillis();
            System.out.println("Total load time: " + (end - start) + " ms");
            System.out.println(atomicInt.get() - 1 + " images loaded");
        } finally {
            executor.shutdown();
        }
    }
}
                    

And when we monitor these requests in TCPView, we got something different:

TCPView HTTP/2

TCPView HTTP/2

Instead of 6 connections like we can see in HTTP/1.1 screenshot, we got only one connection. This is because in HTTP/2 improvements:

  • Single Connection. Only one connection to the server is used to load a website, and that connection remains open as long as the website is open. This reduces the number of round trips needed to set up multiple TCP connections. Since the main page and all image resources are in the same server (http2.golang.org), only one connection is open.
  • Multiplexing. Multiple requests are allowed at the same time, on the same connection. Previously, with HTTP/1.1, each transfer would have to wait for other transfers to complete. All requests to load 180 images is happen in the same connection

Still using JEP321Http2Client.java, now we change our target to https://http2.golang.org/gophertiles?latency=1000. The latency now 1s, and we can see the impact in loading times:

Total load time: 38061 ms 180 images loaded

We can improve loading times by allowing more threads in the custom executor. The latency will remarkably reduced, since more requests are sent in parallel.

When we change number of threads in executor to 30

static ExecutorService executor = Executors.newFixedThreadPool(30, (Runnable r) -> { return new Thread(r); });

The loading time is improved:

Total load time: 11050 ms 180 images loaded