Building a High-Performance TCP Long-Connection Gateway with Spring Boot and Netty: Protocol, Heartbeat & Cluster Broadcast

This article details integrating Netty with Spring Boot to build a scalable TCP long-connection gateway, covering binary protocol design with length-field framing, heartbeat detection using IdleStateHandler, session management with Redis and local maps, single-node and Redis Pub/Sub cluster push mechanisms, and performance tuning tips including Epoll transport and thread pool isolation.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Building a High-Performance TCP Long-Connection Gateway with Spring Boot and Netty: Protocol, Heartbeat & Cluster Broadcast

Why Long Connections?

HTTP short connections require TCP handshake per request and cannot push data from server to client. Long connections keep a persistent TCP channel, enabling millisecond-level server push. Typical scenarios include IoT devices (smart meters, charging piles, industrial sensors) that stay connected for months in weak networks, IM requiring real-time message delivery with ordering and multi-device sync, and stock quote pushes with hundreds of updates per second where polling fails.

Common gateway requirements: support hundreds of thousands of connections per node, low forwarding latency, custom binary protocol to save bandwidth and parsing cost, and stable connections with timely cleanup of dead connections to avoid file descriptor and memory exhaustion.

Core Netty Concepts

EventLoop: The Looping Thread

An EventLoop is a single-threaded event loop bound to a Selector, handling I/O events for multiple Channels. Each Channel belongs to one EventLoop for its lifetime, so all operations on that Channel run in the same thread, eliminating concurrency concerns. Servers typically use two EventLoopGroups: bossGroup (1 thread) accepts new connections and hands them to workerGroup (default CPU cores × 2) for I/O and business logic.

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); // default CPU cores * 2

Pipeline: The Responsibility Chain

Each Channel owns a ChannelPipeline containing a chain of ChannelHandlers. Inbound data flows from head to tail; outbound flows from tail to head. Decoding, business logic, and exception handling can be separate handlers, added or removed dynamically. Example pipeline for the gateway:

pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(...));
pipeline.addLast("binaryDecoder", new MsgDecoder());
pipeline.addLast("businessHandler", new BusinessHandler());

ChannelHandler: Business Logic Container

Custom logic goes into handlers, commonly extending SimpleChannelInboundHandler<T> which auto-releases reference-counted objects and dispatches by generic type. Netty provides built-in handlers like LengthFieldBasedFrameDecoder, IdleStateHandler, and ByteToMessageDecoder.

public class EchoHandler extends SimpleChannelInboundHandler<Message> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Message msg) {
        // handle business message
    }
}

Spring Boot Integration

Dependencies and Configuration

Add Netty dependency (version 4.1.100.Final). Externalize port, thread counts, timeouts, and max frame length via @ConfigurationProperties:

@Data
@ConfigurationProperties(prefix = "netty.server")
public class NettyServerProperties {
    private int port = 8000;
    private int bossThreads = 1;
    private int workerThreads = 0; // 0 = default
    private int maxFrameLength = 1024;
    private int readerIdleTimeSeconds = 60;
    private int writerIdleTimeSeconds = 30;
    private int allIdleTimeSeconds = 90;
}

Server Startup Bean

A @Component with @PostConstruct starts the server using ServerBootstrap, configures socket options (SO_BACKLOG=1024, SO_REUSEADDR, TCP_NODELAY, SO_KEEPALIVE), and registers a ChannelInitializer to assemble each connection's pipeline. @PreDestroy shuts down groups gracefully.

@Component
@RequiredArgsConstructor
public class NettyServer {
    private final NettyServerProperties props;
    private final ChannelInitializer<SocketChannel> channelInitializer;
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;
    private Channel serverChannel;

    @PostConstruct
    public void start() throws InterruptedException {
        bossGroup = new NioEventLoopGroup(props.getBossThreads());
        workerGroup = new NioEventLoopGroup(props.getWorkerThreads());
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .option(ChannelOption.SO_BACKLOG, 1024)
            .option(ChannelOption.SO_REUSEADDR, true)
            .childOption(ChannelOption.TCP_NODELAY, true)
            .childOption(ChannelOption.SO_KEEPALIVE, true)
            .childHandler(channelInitializer);
        serverChannel = bootstrap.bind(props.getPort()).sync().channel();
        log.info("Netty server started on port {}", props.getPort());
    }

    @PreDestroy
    public void shutdown() {
        if (serverChannel != null) serverChannel.close();
        if (bossGroup != null) bossGroup.shutdownGracefully();
        if (workerGroup != null) workerGroup.shutdownGracefully();
    }
}

ChannelInitializer: Don't Inject Handlers Directly

Most ChannelHandlers are stateful (e.g., ByteToMessageDecoder has internal buffers). Registering them as Spring singletons and injecting into the initializer causes data cross-talk across Channels. Correct approach: instantiate handlers inside initChannel, passing Spring-managed dependencies (e.g., DeviceSessionManager) via constructor. If a handler is truly stateless, annotate with @Sharable, but simply new is preferred.

@Component
@RequiredArgsConstructor
public class GatewayChannelInitializer extends ChannelInitializer<SocketChannel> {
    private final NettyServerProperties props;
    private final DeviceSessionManager sessionManager;
    private final ChannelRegistry channelRegistry;

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();
        // Idle detection (heartbeat)
        pipeline.addLast("idleStateHandler",
            new IdleStateHandler(props.getReaderIdleTimeSeconds(),
                props.getWriterIdleTimeSeconds(),
                props.getAllIdleTimeSeconds()));
        // Frame decoder (length field)
        pipeline.addLast("frameDecoder",
            new LengthFieldBasedFrameDecoder(props.getMaxFrameLength(),
                0, 4, 0, 4));
        // Custom codec
        pipeline.addLast("protocolDecoder", new ProtocolDecoder());
        pipeline.addLast("protocolEncoder", new ProtocolEncoder());
        // Heartbeat handler
        pipeline.addLast("heartbeatHandler", new HeartbeatHandler());
        // Business dispatcher
        pipeline.addLast("dispatchHandler",
            new MessageDispatchHandler(sessionManager, channelRegistry));
    }
}

Thread Model Pitfall

Blocking operations (DB queries, remote calls) in an EventLoop thread stall that thread, affecting all Channels it serves. Two solutions: (1) offload to a separate business thread pool with async callbacks; (2) assign a dedicated EventExecutorGroup to specific handlers via

pipeline.addLast(bizGroup, "businessHandler", new BusinessHandler())

. The author prefers the first for easier debugging.

Protocol Design: Binary Framing

To save bandwidth, a compact binary header is used:

+--------+--------+---------+
| Length | Type   | Payload |
| 4 bytes| 1 byte | N bytes |
+--------+--------+---------+

Length: 4 bytes, total length of Type + Payload (excludes Length itself).

Type: 1 byte, e.g., 0x01=heartbeat, 0x02=auth, 0x03=business data.

Payload: business data (JSON, Protobuf, or raw bytes).

TCP is a stream protocol; messages may be split or merged. LengthFieldBasedFrameDecoder handles framing by extracting complete frames based on the length field:

new LengthFieldBasedFrameDecoder(
    maxFrameLength, // max frame length, prevents OOM from malicious large frames
    0,              // length field starts at byte 0
    4,              // length field is 4 bytes
    0,              // no length adjustment
    4,              // strip the length field after decoding
    true            // fail fast if frame exceeds maxFrameLength
)

Additional safeguards: max frame length set to 4KB, validate Type field, require authentication within 5 seconds of connection, otherwise close.

Decoder and Encoder

Decoder extends ByteToMessageDecoder, reads Type and Payload into a Message POJO:

public class ProtocolDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        if (in.readableBytes() < 1) return;
        byte type = in.readByte();
        byte[] payload = new byte[in.readableBytes()];
        in.readBytes(payload);
        out.add(new Message(type, payload));
    }
}

Encoder extends MessageToByteEncoder<Message>:

public class ProtocolEncoder extends MessageToByteEncoder<Message> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) {
        out.writeInt(msg.getPayload().length + 1); // Length = Type + Payload
        out.writeByte(msg.getType());
        out.writeBytes(msg.getPayload());
    }
}
Message

is a simple POJO with byte type and byte[] payload.

Heartbeat Detection: Preventing Dead Connections

Clients may lose power or network without sending RST, leaving server-side connections dangling. IdleStateHandler triggers read idle, write idle, and all idle events. Configured as read idle 60s, write idle 30s, all idle 90s. HeartbeatHandler reacts:

@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) evt;
            if (event.state() == IdleState.ALL_IDLE) {
                log.info("Channel {} idle too long, closing", ctx.channel().id());
                ctx.close();
            } else if (event.state() == IdleState.READER_IDLE) {
                log.warn("Read idle, no data received from {}", ctx.channel().remoteAddress());
                // could send ping, close after multiple failures
                ctx.close();
            }
        }
    }
}

An online registry maps deviceId to Channel. Uses Redis ( StringRedisTemplate) with 120s TTL for cluster-wide visibility, plus a local ConcurrentHashMap for fast local push. On heartbeat, refresh Redis TTL.

@Component
public class DeviceSessionManager {
    private final ConcurrentHashMap<String, Channel> localChannels = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<Channel, String> channelToDeviceId = new ConcurrentHashMap<>();
    private final StringRedisTemplate redisTemplate;
    private static final String ONLINE_KEY_PREFIX = "device:online:";

    public void online(String deviceId, Channel channel) {
        localChannels.put(deviceId, channel);
        channelToDeviceId.put(channel, deviceId);
        redisTemplate.opsForValue().set(ONLINE_KEY_PREFIX + deviceId,
            channel.id().asLongText(), Duration.ofSeconds(120));
    }

    public void heartbeat(String deviceId) {
        redisTemplate.expire(ONLINE_KEY_PREFIX + deviceId, Duration.ofSeconds(120));
    }

    public void offline(String deviceId) {
        Channel ch = localChannels.remove(deviceId);
        if (ch != null) channelToDeviceId.remove(ch);
        redisTemplate.delete(ONLINE_KEY_PREFIX + deviceId);
    }

    public void removeByChannel(Channel channel) {
        String deviceId = channelToDeviceId.remove(channel);
        if (deviceId != null) {
            localChannels.remove(deviceId);
            redisTemplate.delete(ONLINE_KEY_PREFIX + deviceId);
        }
    }

    public Channel getLocalChannel(String deviceId) {
        return localChannels.get(deviceId);
    }
}

Heartbeat message: Type 0x01 request with deviceId payload; server replies Type 0x02 with "pong". Handled in MessageDispatchHandler:

if (msg.getType() == 0x01) {
    String deviceId = new String(msg.getPayload(), StandardCharsets.UTF_8);
    sessionManager.heartbeat(deviceId);
    ctx.writeAndFlush(new Message((byte) 0x02, "pong".getBytes()));
}

Active Push: Single-Node vs Cluster

Single-Node Push

Maintain a global ChannelGroup (thread-safe via GlobalEventExecutor). Add on channelActive, remove on channelInactive. Push to a specific device by looking up its local Channel.

@Component
public class ChannelRegistry {
    public static final ChannelGroup ALL_CHANNELS =
        new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
    ChannelRegistry.ALL_CHANNELS.add(ctx.channel());
}

@Override
public void channelInactive(ChannelHandlerContext ctx) {
    ChannelRegistry.ALL_CHANNELS.remove(ctx.channel());
    sessionManager.removeByChannel(ctx.channel());
}
public void pushToDevice(String deviceId, String payload) {
    Channel channel = sessionManager.getLocalChannel(deviceId);
    if (channel != null && channel.isActive()) {
        channel.writeAndFlush(new Message((byte) 0x03, payload.getBytes()));
    }
}

Cluster Push via Redis Pub/Sub

In a multi-node deployment (A, B, C), device X connected to A; request arrives at B. B lacks X's Channel, so it publishes to a shared Redis channel (e.g., netty:push). All nodes subscribe; only the node holding X's Channel delivers the push. Since publishers only broadcast when local lookup fails, self-received messages are ignored (local Channel still absent). For broadcast to all devices, each node iterates its own ChannelGroup.

Use Spring Data Redis RedisMessageListenerContainer with a listener parsing JSON messages (avoid colon-delimited strings which break if payload contains colons):

{
  "deviceId": "xxx",
  "payload": "....."
}

Note: Redis Pub/Sub does not persist messages; for guaranteed delivery, replace with RabbitMQ or RocketMQ broadcast mode.

Load Testing and Optimizations

Test with multiple client machines (10k connections each) sending periodic heartbeats. Key metrics: QPS, avg/max latency, CPU, file descriptors, GC frequency. On 8C16G, 100k idle heartbeat connections consumed ~30% CPU with sub-1ms latency. Blocking DB calls in handlers spiked CPU to 90%+ and caused latency jitter.

Optimization tips:

Business thread pool isolation : EventLoop only does I/O; offload blocking work to a separate pool with async callbacks.

Reduce object allocation : Avoid frequent String concatenation in decoders; use byte arrays or ByteBuf directly. Monitor GC; frequent Full GC is fatal.

Tune OS parameters : somaxconn, tcp_max_syn_backlog, max_map_count must be raised for high connection counts.

Use Epoll transport on Linux : Netty's NIO is cross-platform but has overhead. Switch to EpollEventLoopGroup and EpollServerSocketChannel when available:

if (Epoll.isAvailable()) {
    bossGroup = new EpollEventLoopGroup(1);
    workerGroup = new EpollEventLoopGroup();
    bootstrap.channel(EpollServerSocketChannel.class);
} else {
    bossGroup = new NioEventLoopGroup(1);
    workerGroup = new NioEventLoopGroup();
    bootstrap.channel(NioServerSocketChannel.class);
}

Additional testing advice: use multiple client machines to avoid file descriptor limits; test push-heavy scenarios; configure WRITE_BUFFER_WATER_MARK (high/low water marks) to prevent slow consumers from blowing up write buffers.

Closing Remarks

Netty handles the low-level stability; focus energy on protocol design and connection management. Remaining production concerns include TLS/SSL, rate limiting, circuit breaking, connection migration, and canary releases. The provided code is simplified from a real charging-pile platform; adjust protocol format, heartbeat intervals, thread pool sizes to your context. Start with a single-node version, verify, then add clustering—don't jump straight to Redis Pub/Sub.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Nettyperformance tuningTCPSpring BootclusterIoTGatewayLong ConnectionEpollHeartbeatProtocol DesignRedis Pub/Sub
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.