Aldo Lushkja / blog

Empowering Asynchronous Communication with Netty and Reactive HTTP Client

5 min read

Introduction

In the dynamic landscape of web development, the need for efficient, scalable, and asynchronous communication has become paramount. Netty, a robust and high-performance networking framework, coupled with a reactive HTTP client, offers a powerful solution to meet these demands. In this article, we will explore the fundamentals of Netty, build a request with the raw Netty API, and then see why higher-level reactive clients like Spring’s WebClient are usually the better default — because, under the hood, they’re Netty too.

Understanding Netty

Netty, an open-source framework, excels in building high-performance and scalable network applications. Its asynchronous event-driven architecture is well-suited for handling a large number of connections simultaneously, making it a popular choice for building servers and clients in various domains.

Key Features of Netty

  1. Event-Driven Model: Netty utilizes an event-driven programming model, allowing developers to handle I/O operations asynchronously. This helps in achieving high concurrency and responsiveness in applications.

  2. Channel Abstraction: Netty’s channel abstraction simplifies network communication by providing a consistent interface for various transport types, such as sockets or Datagram channels.

  3. Thread Pools: Netty efficiently manages threads through its customizable EventLoopGroup, optimizing resource utilization and ensuring smooth operation in high-throughput scenarios.

A Raw Netty HTTP Client

Before reaching for a higher-level abstraction, it’s worth seeing what Netty itself asks of you: an EventLoopGroup to run the I/O threads, a Bootstrap to configure the client, and a pipeline of handlers to encode the request and decode the response.

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;

public class NettyHttpClient {
    private final EventLoopGroup group = new NioEventLoopGroup();

    public void get(String host, int port, String path) throws InterruptedException {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(new HttpClientCodec());
                        ch.pipeline().addLast(new HttpObjectAggregator(1024 * 1024));
                        ch.pipeline().addLast(new SimpleChannelInboundHandler<FullHttpResponse>() {
                            @Override
                            protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) {
                                System.out.println("Status: " + response.status());
                                System.out.println("Body: " + response.content().toString(CharsetUtil.UTF_8));
                            }
                        });
                    }
                });

        Channel channel = bootstrap.connect(host, port).sync().channel();

        HttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, path);
        request.headers().set(HttpHeaderNames.HOST, host);
        request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);

        channel.writeAndFlush(request);
        channel.closeFuture().sync();
    }

    public void shutdown() {
        group.shutdownGracefully();
    }
}

This works, but notice everything you’re now responsible for: aggregating chunked responses, closing the channel, shutting the EventLoopGroup down, and — if you want more than one in-flight request — connection pooling. That’s exactly the gap reactive HTTP clients fill.

Reactive HTTP Client

A reactive HTTP client wraps that same Netty machinery behind an API built around asynchronous data streams. Spring WebFlux’s WebClient, for instance, doesn’t reimplement networking — by default its HTTP connector is Reactor Netty, a thin reactive layer over the exact Bootstrap/EventLoopGroup/Channel primitives shown above. You get Netty’s throughput with connection pooling, backpressure, and composable operators handled for you.

Advantages of a Reactive HTTP Client over Raw Netty

  1. Asynchronous Communication: Reactive HTTP clients, such as WebClient or Project Reactor’s Mono and Flux, let you perform HTTP requests asynchronously without hand-rolling channel handlers for every call site.

  2. Streamlined Error Handling: Reactive programming facilitates concise error handling through operators like onErrorResume and doOnError, instead of catching exceptions inside a ChannelInboundHandler.

  3. Backpressure Support: Reactive HTTP clients come with built-in backpressure, letting a slow consumer signal upstream instead of buffering unboundedly — something the raw client above does not handle at all.

Sample Code: Sending a Request with WebClient

import reactor.core.publisher.Mono;
import org.springframework.web.reactive.function.client.WebClient;

public class ReactiveHttpClientExample {
    private final WebClient webClient;

    public ReactiveHttpClientExample() {
        this.webClient = WebClient.builder()
                .baseUrl("http://example.com")
                .build();
    }

    public Mono<String> sendRequest() {
        return webClient.get()
                .uri("/api/data")
                .retrieve()
                .bodyToMono(String.class)
                .doOnError(error -> System.err.println("Error occurred: " + error.getMessage()));
    }
}

Best Practices

  1. Share the EventLoopGroup — whether you’re using raw Netty or Reactor Netty’s HttpClient, creating a new EventLoopGroup per request defeats the whole point of the event-driven model. Create one per application (or reuse WebClient’s default, which is already shared) and reuse it.

  2. Always shut down gracefullygroup.shutdownGracefully() for raw Netty, or let Spring manage the WebClient bean’s lifecycle. Leaked event loop threads are a common source of non-daemon threads keeping a JVM alive after shutdown.

  3. Set explicit timeouts — neither raw Netty nor WebClient time out by default. Configure connect/read/response timeouts explicitly; an unresponsive downstream service will otherwise hold a connection (and, with raw Netty, block closeFuture().sync()) indefinitely.

Common Pitfalls

  1. Blocking calls inside the event loop

    • Any blocking I/O (JDBC, Thread.sleep, synchronous file access) executed on a Netty event loop thread stalls every other channel that thread services — Netty’s threading model has no separate thread pool for you by default.

    • With WebClient, calling .block() inside a reactive chain reintroduces the same problem: it ties up the event loop thread waiting synchronously.

  2. Not aggregating chunked responses

    • Without HttpObjectAggregator (raw Netty) the handler receives HttpContent fragments, not a complete body — a frequent source of “it works for small responses, breaks on large ones” bugs.
  3. Unbounded connection pools

    • Reactor Netty’s default connection pool has a finite size; under load without tuning it, requests queue rather than fail fast. Size the pool and set an acquire timeout deliberately instead of discovering the default under production traffic.

Conclusion

In the ever-evolving landscape of web development, Netty and reactive HTTP clients provide a robust foundation for building high-performance, asynchronous, and scalable applications. Understanding the raw Netty client makes clear what a reactive client like WebClient is actually doing for you — pooling, backpressure, and lifecycle management — so you reach for the abstraction deliberately rather than by default, and know what to check first when it misbehaves.

Additional Resources