skip to content

Sketch how you would build a scalable TCP echo server with AsynchronousServerSocketChannel and CompletionHandler. What are the key patterns and pitfalls?

level: seniorimportance: nice to knowfreq 25%

answer

  1. Re-arm accept first thing in the accept handler
  2. flip() before write, clear() after write
  3. read == -1 means EOF, close the channel
  4. Per-connection buffer via the attachment
  5. No blocking/heavy work in handlers; add back-pressure

basics

~20 s

Open an AsynchronousServerSocketChannel, bind a port, and call accept with a handler. When a client connects, the handler gets the new connection, immediately calls accept again for the next client, and starts reading. Each read's handler writes the data back, then reads again.

solid answer

~50 s

You open an AsynchronousServerSocketChannel bound to a port and tied to a channel group, then call accept(attachment, handler). The accept handler's completed() receives the new AsynchronousSocketChannel; the first thing it does is call accept() again so the server keeps listening, then it starts a read on the new connection. The read handler echoes by writing the buffer back, and on write completion issues another read, forming a non-blocking read/write loop per connection driven entirely by callbacks. Pitfalls: you must re-arm accept or the server stops after one client; handle partial reads/writes since a single operation may transfer fewer bytes than requested; never do blocking or heavy work in a handler (it starves the group pool); manage buffer lifecycle and flip()/clear() correctly; close channels on EOF (read returns -1) or error in failed(); and apply back-pressure so a slow client cannot make you queue unbounded reads. Sizing the channel group governs scalability.

code

java · 25 lines
java
AsynchronousServerSocketChannel server =
        AsynchronousServerSocketChannel.open(group)
                .bind(new InetSocketAddress(9000));

server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
    public void completed(AsynchronousSocketChannel client, Void att) {
        server.accept(null, this);          // RE-ARM: keep listening
        ByteBuffer buf = ByteBuffer.allocate(1024);
        client.read(buf, buf, new CompletionHandler<Integer, ByteBuffer>() {
            public void completed(Integer n, ByteBuffer b) {
                if (n == -1) { closeQuietly(client); return; }   // EOF
                b.flip();
                client.write(b, b, new CompletionHandler<Integer, ByteBuffer>() {
                    public void completed(Integer w, ByteBuffer wb) {
                        wb.clear();
                        client.read(wb, wb, /* this read handler */ this == null ? null : null);
                    }
                    public void failed(Throwable t, ByteBuffer wb) { closeQuietly(client); }
                });
            }
            public void failed(Throwable t, ByteBuffer b) { closeQuietly(client); }
        });
    }
    public void failed(Throwable t, Void att) { /* log; optionally re-accept */ }
});

go deeper

for a junior

Understands the high-level flow: accept a client, read, echo it back, read again.

for a middle

Can wire accept/read/write handlers and knows to re-arm accept and handle EOF.

for a senior

Handles partial transfers, buffer flip/clear discipline, error paths, back-pressure, and pool-starvation avoidance.

for a principal

Compares against virtual-thread and reactive designs, reasons about back-pressure, resource limits, and operational lifecycle at scale.

### Goal An **echo server** accepts TCP connections and sends back whatever a client sends. We want it to handle many clients without one thread per connection, using NIO.2 async channels. ### The building blocks - **AsynchronousServerSocketChannel** — the listening socket; its `accept` operation completes when a client connects, yielding an **AsynchronousSocketChannel** (the per-client connection). - **CompletionHandler<V,A>** — the callback with `completed(result, attachment)` and `failed(throwable, attachment)`. - **AsynchronousChannelGroup** — the thread pool running all those callbacks. - **ByteBuffer** — the byte container read into / written from. ### The flow (all callback-driven, no blocking loop) 1. Open and bind the server channel, then call `accept(attachment, acceptHandler)`. `accept` returns immediately. 2. When a client connects, `acceptHandler.completed(clientChannel, ...)` runs on a pool thread. **First action: call `serverChannel.accept(...)` again** — otherwise the server accepts exactly one client and then goes deaf. This *re-arming* is the single most important pattern. 3. Then start reading on `clientChannel`: `clientChannel.read(buffer, buffer, readHandler)`. 4. `readHandler.completed(bytesRead, buffer)` runs when data arrives. If `bytesRead == -1`, the client closed the connection → close `clientChannel`. Otherwise `buffer.flip()` and `clientChannel.write(buffer, buffer, writeHandler)` to echo it back. 5. `writeHandler.completed(...)` then `buffer.clear()` and issues the next `read`, looping the connection. This forms a per-connection state machine implemented purely as callbacks; the few pool threads multiplex thousands of connections. ### Key patterns - **Re-arm accept immediately** inside the accept handler (step 2). - **Buffer discipline**: after a read, `flip()` to switch from filling to draining before writing; after a write, `clear()` (or `compact()`) before the next read. - **Partial transfers**: a read or write may move fewer bytes than the buffer holds; loop or track offsets until the intended amount is handled — never assume one call moved everything. - **EOF handling**: read result `-1` means peer closed; close the channel. - **Error handling**: implement `failed()` to close the channel and clean up; do not let exceptions vanish. ### Pitfalls - **Forgetting to re-arm accept** → server handles one client only. - **Blocking/heavy work in a handler** → it occupies a group pool thread and starves all other connections; offload to a separate executor. - **Unbounded outstanding operations / no back-pressure** → a slow or malicious client can make you allocate buffers and queue work without limit; cap concurrent reads or use bounded buffers. - **Shared mutable buffer across operations** without proper per-connection ownership → data corruption; give each connection its own buffer (often passed as the attachment). - **Resource leaks** → close channels on EOF/error and shut the group down on stop. ### When to use it This model predates virtual threads. With Project Loom, a far simpler blocking thread-per-connection server on virtual threads often scales comparably with much clearer code, so reach for async channels mainly when you specifically need the completion-based API or are on an older runtime.

  • Why must the accept handler call accept again?
    Each accept call services exactly one incoming connection. Without re-arming by calling accept again, the server stops listening after the first client. Re-arming inside completed() keeps a continuous accept loop alive without a manual blocking loop.
  • How would virtual threads change this design?
    With virtual threads you can write a simple blocking thread-per-connection server that scales to many connections cheaply, eliminating the callback state machine. The async-channel approach becomes mostly relevant for older runtimes or when an explicitly completion-based API is required.

saying these in an interview costs you the question

  • Not re-arming accept, so only one client is served
  • Assuming one read/write transfers all requested bytes
  • Sharing one buffer across connections
  • Blocking inside a handler and starving the pool
  • Ignoring EOF (-1) and failed(), leaking channels

context