Enforcing Multi-Device Login Limits with Spring Boot and Netty

Configuration Parameters

Define the maximum number of concurrent connections per user and the WebSocket endpoint in application.yml:

netty:
  port: 8082
  maxDevices: 2
  path: /ws

A @ConfigurationProperties class binds these values:

@ConfigurationProperties(prefix = "netty")
@Component
public class NettyProperties {
    private int port = 8082;
    private int maxDevices = 2;
    private String path = "/ws";
    // getters and setters
}

Channel Initialization

Configure the Netty pipeline to support WebSocket handshakes and frame processing:

@Component
@RequiredArgsConstructor
public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> {

    private static final String WS_PROTOCOL = "WebSocket";
    private final WebSocketMessageHandler messageHandler;

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();
        pipeline.addLast(new HttpServerCodec());
        pipeline.addLast(new ChunkedWriteHandler());
        pipeline.addLast(new HttpObjectAggregator(8192));
        pipeline.addLast(new WebSocketServerProtocolHandler("/ws", WS_PROTOCOL, true, 65536 * 10));
        pipeline.addLast(messageHandler);
    }
}

Connection Registry

Store active channels per user in a concurrent map. Each user maps to a list that preserves connection order:

@Component
public class UserChannelRepository {

    private final ConcurrentHashMap<String, List<Channel>> userChannels = new ConcurrentHashMap<>();
    private final NettyProperties nettyProperties;

    public UserChannelRepository(NettyProperties nettyProperties) {
        this.nettyProperties = nettyProperties;
    }

    public void register(String userId, Channel channel) {
        List<Channel> channels = userChannels.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>());
        channels.add(channel);
        enforceDeviceLimit(userId);
    }

    public void remove(String userId, Channel channel) {
        List<Channel> channels = userChannels.get(userId);
        if (channels != null) {
            channels.remove(channel);
            if (channels.isEmpty()) {
                userChannels.remove(userId);
            }
        }
    }

    private void enforceDeviceLimit(String userId) {
        List<Channel> channels = userChannels.get(userId);
        if (channels == null) return;
        while (channels.size() > nettyProperties.getMaxDevices()) {
            Channel oldest = channels.remove(0);
            if (oldest != null && oldest.isActive()) {
                oldest.writeAndFlush(new TextWebSocketFrame("Connection replaced by newer device"));
                oldest.close();
            }
        }
    }
}

CopyOnWriteArrayList allows safe iteration while a new channel is added or the oldest one is removed. The oldest entry (index 0) represents the earliest established connection and gets evicted first when the limit is exceeded.

Handling WebSocket Lifecycle

The handler ties user identity to a channel. A typical approach obtains the user ID from the handshake request, for example via a query parameter or token:

@Slf4j
@Component
@ChannelHandler.Sharable
public class WebSocketMessageHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    private final UserChannelRepository userChannelRepository;

    public WebSocketMessageHandler(UserChannelRepository userChannelRepository) {
        this.userChannelRepository = userChannelRepository;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // user identification logic omitted for brevity
        String userId = extractUserId(ctx.channel());
        if (userId != null) {
            userChannelRepository.register(userId, ctx.channel());
            ctx.channel().attr(AttributeKey.valueOf("userId")).set(userId);
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        String userId = (String) ctx.channel().attr(AttributeKey.valueOf("userId")).get();
        if (userId != null) {
            userChannelRepository.remove(userId, ctx.channel());
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        ctx.writeAndFlush(new TextWebSocketFrame("Echo: " + frame.text()));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("Channel error", cause);
        ctx.close();
    }

    private String extractUserId(Channel channel) {
        // example: parse from handshake URI
        return null;
    }
}

Server Bootstrap

Start the Netty server as a Spring-managed bean:

@Component
public class NettyWebSocketServer implements DisposableBean {

    private final NettyProperties properties;
    private final WebSocketChannelInitializer initializer;
    private Channel serverChannel;
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;

    public NettyWebSocketServer(NettyProperties properties, WebSocketChannelInitializer initializer) {
        this.properties = properties;
        this.initializer = initializer;
    }

    @PostConstruct
    public void start() throws InterruptedException {
        bossGroup = new NioEventLoopGroup(1);
        workerGroup = new NioEventLoopGroup();
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(initializer)
                .option(ChannelOption.SO_BACKLOG, 128)
                .childOption(ChannelOption.SO_KEEPALIVE, true);
        serverChannel = bootstrap.bind(properties.getPort()).sync().channel();
    }

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

Once started, every new WebSocket connection registers itself under the associated user ID. When the number of active connections exceeds maxDevices, the earliest channel receives a notification and is closed, keeping the user’s active session count with in the configured limit.

Tags: Spring Boot Netty WebSocket Multi-device Login Connection Management

Posted on Sat, 09 May 2026 22:12:21 +0000 by yellowepi