After Day 98, I wanted to test a narrower question: when does a virtual-thread server behave differently from an event-loop server?

I built one server for each model and published the code and benchmark scripts. The comparison does not establish a universal winner. It shows how the models behave under one recorded workload and makes that workload repeatable.

Test context

  • Repository: virtual-thread-eventloop-test
  • Runtime requirement: JDK 21 or later
  • Recorded machine: Windows, 16 CPU cores, 31.82 GB RAM, and 183 GB free disk
  • Server heap: 4 GB for each implementation
  • Load generator: Bombardier with 14 worker threads
  • Commands and raw-analysis steps: benchmarks/README.md and benchmarks/QUICK-REFERENCE.md in the repository

The recorded results are a sample run, not a production load test. Hardware, network topology, request mix, warmup, heap size, and connection behavior can change the outcome.

Why compare the models

Virtual threads make blocking I/O practical at high concurrency while keeping sequential control flow. Event loops use a smaller number of threads and explicit state machines to manage many connections. Both models remain useful because they optimize different constraints.

Virtual threads can unmount from a carrier thread during supported blocking operations, allowing that carrier to run other work. This keeps sequential control flow practical for request handlers that wait on databases, remote APIs, or files.

But reactive frameworks don’t work this way. They use event loops: one thread handles thousands of connections by multiplexing I/O events. No mounting. No unmounting. No stack switching. Just a tight loop reading from a Selector.

I needed to understand both models to know when each wins.

Non-Blocking I/O: The Foundation

Platform-thread-per-request designs can spend substantial resources on threads that are waiting. Virtual threads reduce that cost, but their stack chunks and application state still consume memory, and scheduling is not free. Measure those costs under the intended concurrency and workload.

Non-blocking I/O takes a different approach: one thread, many connections, explicit multiplexing.

What is I/O multiplexing

I/O multiplexing breaks down to kernel-level efficiency: one thread polls multiple file descriptors via system calls like select()/poll()/epoll(), reacting only to ready I/O events to avoid per-connection blocking.

At the OS kernel level, I/O operations cross between user space and kernel space. With a platform-thread-per-connection design, a blocking socket.read() leaves that thread waiting for data. Large connection counts can therefore make thread memory and scheduling part of the system’s resource budget.

Multiplexing inverts this model. Instead of one thread per connection, one thread monitors many connections. The kernel tells you which connections are ready for I/O, and you react only to those.

Here’s how it works at the kernel level:

The select() system call (or epoll on Linux, kqueue on macOS) takes a set of file descriptors with interest operations (read/write/accept), atomically blocks until any file descriptor signals readiness via kernel events, then returns a bitmask of ready file descriptors all without per-fd polling.

How It Works In Java

Java NIO’s Selector wraps this mechanism. On Linux, the JDK can use an epoll-backed selector. The selector maintains registered channels and their interest operations. When you call selector.select(), it waits until at least one channel is ready, then returns the ready keys.

Non-blocking channels ensure read()/write() return immediately they never block. If data isn’t ready, read() returns 0 bytes. If the socket buffer is full, write() returns 0 bytes written. This forces applications to re-check readiness via selector keys in the event loop.

ByteBuffer manages data with position/limit/capacity semantics. After reading, you call flip() to prepare for consumption: it sets limit = position and position = 0. This is critical without flip(), you’ll read from the wrong position or read garbage data.

The Reactor Pattern layers on top of multiplexing. It consists of:

  1. Acceptor: Handles new connections, registers clients with the selector
  2. Demultiplexer: The Selector.select() call that waits for events
  3. Dispatcher: Routes SelectionKey events to appropriate handlers
  4. Handler: Business logic that processes the I/O event

Java frameworks such as Netty and Reactor Netty build on event-loop designs. A Reactor Netty TcpServer, for example, uses Netty event-loop groups and exposes work through reactive streams. The achievable connection count depends on buffers, application state, traffic, operating-system limits, and hardware.

A single-thread event loop processes sequentially: select → dispatch → callback.

If a handler blocks, it stalls the entire loop that’s why reactive frameworks emphasize non-blocking handlers.

The key lifecycle per channel:

  • Register interest operations (e.g., OP_ACCEPT for server sockets, OP_READ for client sockets)
  • select() yields a set of ready operations (readyOps)
  • Process the event (read → flip buffer → handle → set OP_WRITE if partial write)
  • Cancel the key after use to avoid duplicate events

Here’s a minimal example showing the pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();  // Blocks until at least one channel is ready
    
    for (SelectionKey key : selector.selectedKeys()) {
        if (key.isAcceptable()) {
            SocketChannel client = server.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);  // Interest set
        } else if (key.isReadable()) {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            buf.clear();
            int bytes = client.read(buf);
            
            if (bytes > 0) {
                buf.flip();  // Prepare for reading
                // Process buf.array()[0..bytes]
                key.interestOps(SelectionKey.OP_WRITE);  // Switch to write mode
            }
        } else if (key.isWritable()) {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buf = (ByteBuffer) key.attachment();
            client.write(buf);
            
            if (!buf.hasRemaining()) {
                key.interestOps(SelectionKey.OP_READ);  // Switch back to read mode
            }
        }
        
        selector.selectedKeys().remove(key);  // Must remove to avoid reprocessing
    }
}

This ping-pongs data efficiently. For production systems, you’d add write queues (only register OP_WRITE when the queue is non-empty) and handle partial reads/writes properly. See in the below diagram how this event loop works.

The selector waits when no registered I/O is ready. When a channel becomes ready, the loop processes that event without assigning a dedicated platform thread to every idle connection. The design still has scheduling, system-call, buffer, and handler costs; it changes where those costs appear.

Here’s the core pattern using Java NIO:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class NonBlockingServer {
    private static final int PORT = 8080;
    private static final int BUFFER_SIZE = 1024;

    public static void main(String[] args) throws IOException {
        // Open selector - the heart of event multiplexing
        Selector selector = Selector.open();
        
        // Create server socket, make it non-blocking
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress(PORT));
        serverSocket.configureBlocking(false);
        
        // Register interest in accept events
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        
        System.out.println("Non-blocking server started on port " + PORT);
        
        ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
        
        while (true) {
            // Block until events are ready (but only this ONE thread blocks)
            selector.select();
            
            // Get all ready events
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iter = selectedKeys.iterator();
            
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();
                
                if (!key.isValid()) {
                    continue;
                }
                
                if (key.isAcceptable()) {
                    handleAccept(key, selector);
                } else if (key.isReadable()) {
                    handleRead(key, buffer);
                } else if (key.isWritable()) {
                    handleWrite(key);
                }
            }
        }
    }
    
    private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
        ServerSocketChannel serverSocket = (ServerSocketChannel) key.channel();
        SocketChannel client = serverSocket.accept();
        client.configureBlocking(false);
        
        // Register interest in read events for this client
        client.register(selector, SelectionKey.OP_READ);
        System.out.println("Accepted connection from " + client.getRemoteAddress());
    }
    
    private static void handleRead(SelectionKey key, ByteBuffer buffer) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        buffer.clear();
        
        int bytesRead;
        try {
            bytesRead = client.read(buffer);
        } catch (IOException e) {
            closeConnection(key);
            return;
        }
        
        if (bytesRead == -1) {
            closeConnection(key);
            return;
        }
        
        // Store the read data for writing back
        buffer.flip();
        key.attach(buffer.duplicate());
        key.interestOps(SelectionKey.OP_WRITE);
    }
    
    private static void handleWrite(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        
        client.write(buffer);
        
        if (!buffer.hasRemaining()) {
            // Done writing, switch back to reading
            key.interestOps(SelectionKey.OP_READ);
            key.attach(null);
        }
    }
    
    private static void closeConnection(SelectionKey key) throws IOException {
        key.cancel();
        key.channel().close();
    }
}

This is the foundation. One thread handles all connections. The Selector monitors multiple channels. When data arrives, the selector wakes up with ready events. We handle them without blocking.

Key insight: selector.select() is the only blocking call. Everything else accept(), read(), write() returns immediately. If data isn’t ready, the operation returns zero bytes. No waiting.

Building an Event Loop HTTP Server

The pattern above is raw NIO. Let’s build something more real: an HTTP server using the event loop pattern.

Event loop = infinite loop + selector + event handlers + state machines.

Here’s an instructional implementation for the comparison:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
package org.example;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class EventLoopHttpServer {
    private static final int PORT = 8081;  // Different port
    private static final int BUFFER_SIZE = 8192;

    private final Selector selector;
    private final ServerSocketChannel serverSocket;
    private final Map<SocketChannel, ConnectionState> connections;

    // Metrics
    private final AtomicLong totalRequests = new AtomicLong(0);
    private final AtomicLong activeConnections = new AtomicLong(0);

    public EventLoopHttpServer() throws IOException {
        this.selector = Selector.open();
        this.serverSocket = ServerSocketChannel.open();
        this.connections = new ConcurrentHashMap<>();

        serverSocket.bind(new InetSocketAddress(PORT));
        serverSocket.configureBlocking(false);
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
    }

    public void start() throws IOException {
        System.out.println("Event Loop HTTP Server started on port " + PORT);
        System.out.println("Thread: " + Thread.currentThread().getName());
        System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());

        // Print stats every 10 seconds
        long lastStatTime = System.currentTimeMillis();

        while (true) {
            selector.select(1000);  // Timeout for stats printing

            long now = System.currentTimeMillis();
            if (now - lastStatTime > 10_000) {
                System.out.printf("Stats - Total requests: %d, Active connections: %d, Thread: %s%n",
                        totalRequests.get(), activeConnections.get(), Thread.currentThread().getName());
                lastStatTime = now;
            }

            Iterator<SelectionKey> iter = selector.selectedKeys().iterator();

            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();

                if (!key.isValid()) {
                    continue;
                }

                try {
                    if (key.isAcceptable()) {
                        handleAccept();
                    } else if (key.isReadable()) {
                        handleRead(key);
                    } else if (key.isWritable()) {
                        handleWrite(key);
                    }
                } catch (IOException e) {
                    closeConnection(key);
                }
            }
        }
    }

    private void handleAccept() throws IOException {
        SocketChannel client = serverSocket.accept();
        if (client == null) {
            return;
        }

        client.configureBlocking(false);
        SelectionKey key = client.register(selector, SelectionKey.OP_READ);

        ConnectionState state = new ConnectionState();
        connections.put(client, state);
        activeConnections.incrementAndGet();

        key.attach(state);
    }

    private void handleRead(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ConnectionState state = (ConnectionState) key.attachment();

        state.readBuffer.clear();
        int bytesRead;

        try {
            bytesRead = client.read(state.readBuffer);
        } catch (IOException e) {
            closeConnection(key);
            return;
        }

        if (bytesRead == -1) {
            closeConnection(key);
            return;
        }

        if (bytesRead == 0) {
            return;
        }

        // Check if we've read a complete HTTP request
        state.readBuffer.flip();
        byte[] data = new byte[state.readBuffer.remaining()];
        state.readBuffer.get(data);
        String request = new String(data, StandardCharsets.UTF_8);

        if (request.contains("\r\n\r\n")) {
            // Complete request received
            totalRequests.incrementAndGet();

            // Simulate work (like the virtual thread version)
            simulateWork();

            prepareResponse(state, request);
            key.interestOps(SelectionKey.OP_WRITE);
        } else {
            // Incomplete request, keep reading
            state.readBuffer.compact();
        }
    }

    private void simulateWork() {
        // Simulate work without blocking the event loop
        // In reality, you'd use async I/O or offload to thread pool
        // For benchmark parity, we'll do a tiny computation
        long start = System.nanoTime();
        while (System.nanoTime() - start < 10_000_000) {
            // Busy-wait for 10ms (simulates non-blocking work)
            // This is NOT how you'd do it in production, but matches the virtual thread version
        }
    }

    private void prepareResponse(ConnectionState state, String request) {
        // Parse request line
        String[] lines = request.split("\r\n");
        String requestLine = lines.length > 0 ? lines[0] : "UNKNOWN";
        String[] parts = requestLine.split(" ");
        String method = parts.length > 0 ? parts[0] : "UNKNOWN";
        String path = parts.length > 1 ? parts[1] : "/";

        // Build response
        String body = String.format(
                "{\"message\":\"Hello from Event Loop\",\"method\":\"%s\",\"path\":\"%s\",\"thread\":\"%s\",\"timestamp\":%d}",
                method, path, Thread.currentThread().getName(), System.currentTimeMillis()
        );

        String response = "HTTP/1.1 200 OK\r\n" +
                "Content-Type: application/json\r\n" +
                "Content-Length: " + body.length() + "\r\n" +
                "Connection: keep-alive\r\n" +
                "\r\n" +
                body;

        state.writeBuffer = ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8));
    }

    private void handleWrite(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ConnectionState state = (ConnectionState) key.attachment();

        if (state.writeBuffer == null) {
            return;
        }

        client.write(state.writeBuffer);

        if (!state.writeBuffer.hasRemaining()) {
            // Response sent, switch back to reading
            state.writeBuffer = null;
            state.readBuffer.clear();
            key.interestOps(SelectionKey.OP_READ);
        }
    }

    private void closeConnection(SelectionKey key) {
        SocketChannel client = (SocketChannel) key.channel();
        connections.remove(client);
        activeConnections.decrementAndGet();

        key.cancel();
        try {
            client.close();
        } catch (IOException e) {
            // Already closing
        }
    }

    private static class ConnectionState {
        ByteBuffer readBuffer = ByteBuffer.allocate(BUFFER_SIZE);
        ByteBuffer writeBuffer = null;
    }

    public static void main(String[] args) throws IOException {
        new EventLoopHttpServer().start();
    }
}

The event-loop server listens on port 8081. This command is a quick exploratory run; the recorded comparison later in the article comes from the repository’s multi-phase benchmark suite.

1
bombardier -c 10000 -d 30s http://localhost:8081/

Each connection is represented by state such as READING → WRITING → READING. The event loop transitions that state when I/O is ready instead of assigning a platform thread to each connection.

Virtual Threads vs Event Loops: The Real Trade-offs

The comparison is easier to reason about as a set of trade-offs:

AspectVirtual Threads (Blocking I/O)Event Loops (Non-blocking I/O)
Programming ModelSequential, imperativeCallback-based, state machines
Memory modelStack chunks plus request stateExplicit connection state and buffers
Scheduling workVirtual-thread scheduling and mount/unmount behaviorEvent dispatch and state-machine transitions
DebuggabilitySequential control flow and familiar stack tracesTraces can cross asynchronous boundaries
Connection ceilingDepends on heap, workload, and runtime behaviorDepends on state, buffers, OS limits, and workload
Code complexityOften simpler for branching business logicRequires disciplined non-blocking handlers and state management
Useful starting pointBlocking request handlers and service orchestrationConnection-heavy infrastructure with controlled I/O scheduling

When to Use Virtual Threads

I use virtual threads when:

Complex business logic: Multiple database calls, service calls, and branching logic can be easier to follow in sequential code.

For example, a request that coordinates several remote calls can retain ordinary control flow while each virtual thread waits independently. Whether that improves maintainability depends on the framework, observability, and team conventions.

Blocking I/O workloads: Virtual threads are a reasonable model to test when requests spend much of their time waiting on supported blocking operations. Heap use, pinning, and downstream limits still need measurement.

Team velocity: Most developers understand sequential code. Onboarding is faster. Code reviews are easier. Bugs are simpler to fix.

Here’s a basic HTTP server implementation using virtual threads:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package org.example;

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;

public class VirtualThreadsHttpServer {
    private static final int PORT = 8080;

    public static void main(String[] args) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);

        // Use virtual thread executor
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());

        server.createContext("/", exchange -> {
            // Simulate some work (database call, REST call, etc.)
            // This would block a platform thread, but virtual thread unmounts
            simulateWork();

            String response = buildResponse();
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, response.length());

            try (OutputStream os = exchange.getResponseBody()) {
                os.write(response.getBytes(StandardCharsets.UTF_8));
            }
        });

        server.start();
        System.out.println("Virtual Threads HTTP Server started on port " + PORT);
        System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());

        // Print stats every 10 seconds
        Thread.startVirtualThread(() -> {
            while (true) {
                try {
                    Thread.sleep(10_000);
                    System.out.printf("Stats - Active threads: %d, Virtual threads: yes%n",
                            Thread.activeCount());
                } catch (InterruptedException e) {
                    break;
                }
            }
        });
    }

    private static void simulateWork() {
        // Simulate blocking I/O (database query, REST call, etc.)
        try {
            Thread.sleep(10);  // 10ms simulated I/O
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private static String buildResponse() {
        return String.format(
                "{\"message\":\"Hello from Virtual Threads\",\"thread\":\"%s\",\"timestamp\":%d}",
                Thread.currentThread().toString(),
                System.currentTimeMillis()
        );
    }
}

Total blocking time: ~10ms per request. With platform threads, this ties up a thread for 10ms. With virtual threads, the carrier thread stays free. The virtual thread unmounts at each blocking call. Other virtual threads run.

When to Use Event Loops

I use event loops when:

Connection-heavy workloads: Event loops are worth testing when explicit control over I/O scheduling and per-connection state is more important than sequential request code.

Simple request/response patterns: API gateways, load balancers, WebSocket servers, streaming proxies. The logic is simple: read request, forward it, write response. State machines work fine here.

Explicit resource control: A small event-loop pool can make thread ownership predictable, but buffers, queues, callbacks, and application state still consume memory. Benchmark the complete process rather than comparing thread counts alone.

The Hybrid Approach

A system can use both models. An event loop can own network I/O while other executors handle work that would otherwise block the loop. The handoff adds coordination cost, so it should be justified by measurements and framework behavior.

Pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Event loop handles network I/O (Netty's EventLoopGroup)
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)  // Event loops
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel ch) {
            ch.pipeline().addLast(new HttpServerCodec());
            ch.pipeline().addLast(new HttpObjectAggregator(65536));
            ch.pipeline().addLast(new SimpleChannelInboundHandler<FullHttpRequest>() {
                @Override
                protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
                    // Dispatch business logic to virtual thread pool
                    Thread.startVirtualThread(() -> {
                        var response = handleBusinessLogic(req);
                        ctx.writeAndFlush(response);
                    });
                }
            });
        }
    });

Event loops handle I/O multiplexing. Virtual threads handle business logic. Best of both worlds.

Benchmarks and Repo: Putting Both to the Test

To validate the trade-offs with real numbers, I added a benchmark suite to a small project that runs both implementations side by side. The repo is virtual-thread-eventloop-test and is set up so you can run the same tests and draw your own conclusions.

Repo Layout

Here is the project link GITHUB .The project contains two HTTP servers and a 4-phase benchmark suite:

  • VirtualThreadsHttpServer (port 8080) Java 21 HttpServer with Executors.newVirtualThreadPerTaskExecutor(). Each request runs on a virtual thread and does ~10 ms simulated blocking work (e.g. DB/REST). Simple sequential handler.
  • EventLoopHttpServer (port 8081) Single-thread NIO server: one Selector, non-blocking ServerSocketChannel/SocketChannel, and the same 10 ms work simulated inside the event loop (no virtual threads). Pure reactor style.

Both servers expose the same JSON endpoint and the same simulated workload so the comparison is about concurrency model, not API shape. Build with Maven; the pom.xml produces two runnable JARs: virtual-thread-app and event-loop-app.

Benchmark Suite (4 Phases)

The benchmarks folder holds a hypothesis-driven suite that measures throughput, latency, and resource use:

PhaseWhat it doesGoal
Phase 1: BaselineFixed loads (e.g. 100, 1K, 10K connections), 10s–300s, multiple runsEstablish normal throughput and latency patterns.
Phase 2: Progressive stressRamp connections from 100 → 50K (e.g. +1K every 30s)Find where each implementation degrades or fails.
Phase 3: SpikeBaseline (1K conn) → spike (10K conn) → back to 1K, repeated cyclesObserve recovery and stability.
Phase 4: EnduranceConstant load (e.g. 5K connections) for several hours per serverCheck for memory growth and long-term stability.

Load is generated with Bombardier (Go-based HTTP benchmark). The repo includes bombardier.exe in the scripts for Windows, so you can run the suite natively. The suite can collect JFR, JMX (e.g. VisualVM), and system metrics; the analyze-and-report script turns raw results into CSVs and a FINAL-REPORT.md in benchmark-results/.../analysis/.

Results From a Sample Run

Test machine (from the benchmark run’s config.json): 16 CPU cores, 31.82 GB RAM, 183 GB free disk. Each server ran with 4 GB heap (-Xmx4096m). Bombardier used 14 worker threads. Windows host.

From one full run (Phase 1–3; Phase 2 summary and report):

  • Peak throughput: Event Loop ~4,627 req/s vs Virtual Threads ~3,926 req/s event loop ahead under this workload.
  • Breaking point (Phase 2): Both hit limits around 15,000 connections in that environment (stress ramp).
  • Winner in this setup: Event Loop, for peak RPS, with both degrading at similar connection counts.

So for this “many connections, small fixed delay per request” scenario, the single-thread event loop gave higher throughput, while virtual threads stayed in the same ballpark and remained predictable. Your mileage will depend on hardware, OS, and actual workload (e.g. real DB or HTTP calls).

How to Run It Yourself

  1. Clone/build: Open the virtual-thread-eventloop-test repo, build with Maven (mvn package). Use JDK 21+.
  2. Start both servers with enough heap (e.g. -Xmx4096m) and JMX if you want VisualVM. Virtual threads on 8080, event loop on 8081.
  3. Run the benchmark orchestrator from the benchmarks folder (see README.md and QUICK-REFERENCE.md). The suite uses Bombardier for load generation (e.g. bombardier.exe on Windows); ensure both servers are reachable from the machine running the benchmark.
  4. Analyze: Run the analysis script to generate FINAL-REPORT.md and the CSV summaries under benchmark-results/.../analysis/.

The README in benchmarks explains the hypothesis template (predict VT vs EL before running), what to monitor in VisualVM, and how to interpret throughput, latency percentiles, and breaking points. Repeating the suite on your own machine is a good way to see how the two models behave under your constraints.

Choosing a model

Here’s my mental model :

For request handlers that spend most of their time waiting on blocking I/O, virtual threads are a reasonable first model to test. Event loops remain useful when connection density, memory, or control over I/O scheduling dominates the design. Measure the real workload before choosing.

Test event loops when: The service owns many connections, handlers can remain non-blocking, and control over I/O scheduling or per-connection state is central to the design.

Test a hybrid when: The network layer benefits from event loops but some application work is clearer or safer on a separate executor. Include the handoff and queueing behavior in the test.

The right tool depends on the workload. Virtual threads make blocking I/O practical at higher concurrency, while event loops retain value when explicit I/O scheduling and connection state are the dominant concerns.

What I Learned

Virtual threads and event loops solve different problems:

Virtual threads: Make many blocking-I/O workloads easier to express with sequential code. They reduce reliance on large platform-thread pools but do not remove downstream capacity limits or the need to observe runtime behavior.

Event loops: Centralize readiness-driven I/O and make connection state explicit. They can suit infrastructure-style workloads when handlers remain non-blocking and the state-machine complexity is acceptable.

Understanding both models gives you the full picture of Java’s I/O concurrency landscape. You can make informed decisions based on your actual constraints, not hype or cargo-culting.

Next time you’re designing a system, ask: What’s the connection pattern? What’s the business logic complexity? What are the memory constraints? Then choose the right model.

Both are tools. The benchmark gives you a starting point, not a verdict.

Limitations

The sample run used one Windows machine and synthetic request patterns. It does not cover production network latency, database contention, long-lived WebSocket traffic, container limits, or failure recovery. Re-run the suite with the traffic shape and resource limits that matter to your system.