May be realted to https://github.com/spring-projects/spring-framework/issues/27503.

I am fiddeling around with a VueJs application maintaining a WS connection to a Spring Boot backend.

This is my WebsocketConfig

@Log4j2
@Configuration
@EnableWebSocketMessageBroker
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
// custom authentication interceptor must have the highest precedence over Spring Security filters
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * The base path for all endpoints where Websocket/ STOMP messages can arrive.
     */
    public static final String WS_MESSAGE_ENDPOINT_BASE_PATH = "/api/socket";

    /**
     * The base path for any subscribable Websocket topics.
     */
    public static final String WS_SUBSCRIPTION_BASE_PATH = "/api/socket/topics";

    /**
     * The decoder for JWTs, Bean from Spring's Security config.
     */
    private final JwtDecoder jwtDecoder;

    private final AuthConverterConfig.Jwt2AuthenticationConverter authenticationConverter;

    @Autowired
    public WebsocketConfig(
        JwtDecoder jwtDecoder,
        AuthConverterConfig.Jwt2AuthenticationConverter authenticationConverter
    ) {
        this.jwtDecoder = jwtDecoder;
        this.authenticationConverter = authenticationConverter;
    }

    @Bean
    @ConditionalOnProperty(value = "core.ignore-ws-settings", havingValue = "false", matchIfMissing = true)
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(2048 * 2048);
        container.setMaxSessionIdleTimeout(2048L * 2048L);
        container.setAsyncSendTimeout(2048L * 2048L);
        container.setMaxBinaryMessageBufferSize(2048 * 2048);
        return container;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        final var heartBeatScheduler = new ThreadPoolTaskScheduler();
        heartBeatScheduler.initialize();

        /*  define topic addresses for posting messages to clients */
        registry
            .enableSimpleBroker(WS_SUBSCRIPTION_BASE_PATH)
            .setTaskScheduler(heartBeatScheduler)
            .setHeartbeatValue(new long[] { 0, 10000 });

        /*  prefix for websocket endpoints called by the clients */
        registry.setApplicationDestinationPrefixes(WS_MESSAGE_ENDPOINT_BASE_PATH);
    }

    /**
     * Configures the API and topic prefixes for the websocket communication.
     *
     * @param registration the websocket transport registration
     */
    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.setMessageSizeLimit(300 * 1024 * 1024); // default : 64 * 1024
        registration.setSendBufferSizeLimit(300 * 1024 * 1024); // default : 512 * 1024
        registration.setSendTimeLimit(50 * 10000); // default : 10 * 10000
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry
            .addEndpoint(RestApiEndpointConfig.WS_HANDSHAKE_BASE_PATH) // handshake URL
            .setAllowedOrigins("*");
    }

    /**
     * Configure custom channel interceptors for the inbound websocket channel.
     *
     * @param registration the WS channel that shall be affected by the interceptor
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(
            new WebsocketAuthenticationExtractingChannelInterceptor(this.jwtDecoder, this.authenticationConverter),
            new WebsocketLoggingChannelInterceptor(),
            new WebsocketStompHeaderExtractingChannelInterceptor()
        );
    }
}

As a client, I use rx-stomp, which is configured as follows:

stompClient.configure({
  brokerURL: globalWebsocketState.brokerUrl,
  reconnectDelay: RECONNECT_DELAY_MILLIS,
  heartbeatIncoming: 0,
  heartbeatOutgoing: 10000,
  debug: (message: string) => {
    if (globalWebsocketState.showDebuggingMessages) {
      console.debug(message);
    }
  },
  beforeConnect: async (client) => {
    const connectHeaders = new StompHeaders();
    connectHeaders["Authorization"] = await _fetchAuthHeaderValue();

    client.configure({
      connectHeaders: connectHeaders,
    });
  },
});

This results in the following behaviour: The connection is established. Then, the client can send 3 heartbeats within the first 30 seconds. The last heartbeat results in an error message from the Spring Boot backend, resulting in a termination of the WS connection

The above can be seen in the following screenshot from the FireFox developer console as well:

Image

Here are the logs of the Spring Boot backend:

2025-06-16T15:29:34.324+02:00  INFO 38992 --- [nio-9090-exec-3] d.g.c.b.R.CustomizedRequestLoggingFilter : STARTING REQUEST: [GET] - /ws
2025-06-16T15:29:34.325+02:00 TRACE 38992 --- [nio-9090-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping      : Mapped to HandlerExecutionChain with [org.springframework.web.socket.server.support.WebSocketHttpRequestHandler@2509259e] and 1 interceptors
2025-06-16T15:29:34.326+02:00 DEBUG 38992 --- [nio-9090-exec-3] o.s.w.s.s.s.WebSocketHttpRequestHandler  : GET /ws
2025-06-16T15:29:34.326+02:00 TRACE 38992 --- [nio-9090-exec-3] o.s.w.s.s.s.DefaultHandshakeHandler      : Processing request http://localhost:9090/ws with headers=[host:"localhost:9090", user-agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0", accept:"*/*", accept-language:"de,en-US;q=0.7,en;q=0.3", accept-encoding:"gzip, deflate, br, zstd", sec-websocket-version:"13", origin:"http://localhost:8500", sec-websocket-protocol:"v12.stomp, v11.stomp, v10.stomp", sec-websocket-extensions:"permessage-deflate", sec-websocket-key:"eeXP+3Tqesv73PHBpAMPdw==", connection:"keep-alive, Upgrade", sec-fetch-dest:"empty", sec-fetch-mode:"websocket", sec-fetch-site:"same-site", pragma:"no-cache", cache-control:"no-cache", upgrade:"websocket"]
2025-06-16T15:29:34.326+02:00 TRACE 38992 --- [nio-9090-exec-3] o.s.w.s.s.s.DefaultHandshakeHandler      : Upgrading to WebSocket, subProtocol=v12.stomp, extensions=[]
2025-06-16T15:29:34.335+02:00  INFO 38992 --- [nio-9090-exec-3] d.g.c.b.R.CustomizedRequestLoggingFilter : FINISHED REQUEST: [GET] - /ws
i> total execution time: 11ms
2025-06-16T15:29:34.342+02:00 DEBUG 38992 --- [nio-9090-exec-3] s.w.s.h.LoggingWebSocketHandlerDecorator : New StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:29:34.343+02:00 TRACE 38992 --- [nio-9090-exec-3] s.w.s.h.LoggingWebSocketHandlerDecorator : Handling TextMessage payload=[CONNECT
Au..], byteCount=2725, last=true] in StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:29:34.344+02:00 TRACE 38992 --- [nio-9090-exec-3] o.s.w.s.m.StompSubProtocolHandler        : From client: CONNECT session=7ff0a794-acd6-312f-3032-22f6206b3e60
2025-06-16T15:29:34.346+02:00 DEBUG 38992 --- [nio-9090-exec-3] s.s.i.WebsocketLoggingChannelInterceptor : WS >> CONNECT from "781bb818-9081-4dcb-8bc5-368d7808ea20"
2025-06-16T15:29:34.349+02:00 TRACE 38992 --- [ntextThread-193] o.s.w.s.adapter.NativeWebSocketSession   : Sending TextMessage payload=[CONNECTED
..], byteCount=90, last=true], StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:29:44.363+02:00 TRACE 38992 --- [nio-9090-exec-5] s.w.s.h.LoggingWebSocketHandlerDecorator : Handling TextMessage payload=[
], byteCount=1, last=true] in StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:29:44.363+02:00 TRACE 38992 --- [nio-9090-exec-5] o.s.w.s.m.StompSubProtocolHandler        : From client: heart-beat in session 7ff0a794-acd6-312f-3032-22f6206b3e60
2025-06-16T15:29:54.370+02:00 TRACE 38992 --- [nio-9090-exec-7] s.w.s.h.LoggingWebSocketHandlerDecorator : Handling TextMessage payload=[
], byteCount=1, last=true] in StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:29:54.371+02:00 TRACE 38992 --- [nio-9090-exec-7] o.s.w.s.m.StompSubProtocolHandler        : From client: heart-beat in session 7ff0a794-acd6-312f-3032-22f6206b3e60
2025-06-16T15:30:04.434+02:00 TRACE 38992 --- [nio-9090-exec-8] s.w.s.h.LoggingWebSocketHandlerDecorator : Handling TextMessage payload=[
], byteCount=1, last=true] in StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:30:04.434+02:00 TRACE 38992 --- [nio-9090-exec-8] o.s.w.s.m.StompSubProtocolHandler        : From client: heart-beat in session 7ff0a794-acd6-312f-3032-22f6206b3e60
2025-06-16T15:30:09.180+02:00 TRACE 38992 --- [ntextThread-194] o.s.w.s.adapter.NativeWebSocketSession   : Sending TextMessage payload=[ERROR
mess..], byteCount=49, last=true], StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:30:09.181+02:00 DEBUG 38992 --- [ntextThread-194] o.s.w.s.adapter.NativeWebSocketSession   : Closing StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws]
2025-06-16T15:30:09.181+02:00 DEBUG 38992 --- [ntextThread-194] s.w.s.h.LoggingWebSocketHandlerDecorator : StandardWebSocketSession[id=7ff0a794-acd6-312f-3032-22f6206b3e60, uri=ws://localhost:9090/ws] closed with CloseStatus[code=1002, reason=null]
2025-06-16T15:30:09.181+02:00 DEBUG 38992 --- [ntextThread-194] o.s.w.s.m.SubProtocolWebSocketHandler    : Clearing session 7ff0a794-acd6-312f-3032-22f6206b3e60
2025-06-16T15:30:09.182+02:00  INFO 38992 --- [ntextThread-194] u.s.CoreUserInteractionManagementService : Session for client 781bb818-9081-4dcb-8bc5-368d7808ea20 and session ID 7ff0a794-acd6-312f-3032-22f6206b3e60 ended.
2025-06-16T15:30:09.182+02:00  INFO 38992 --- [ntextThread-194] u.s.CoreUserInteractionManagementService : Removing all stale user interactions with session ID 7ff0a794-acd6-312f-3032-22f6206b3e60 from user interactions repository.
2025-06-16T15:30:09.182+02:00  INFO 38992 --- [ntextThread-194] u.s.CoreUserInteractionManagementService : No stale user interactions for session ID 7ff0a794-acd6-312f-3032-22f6206b3e60 were found. Aborting clean up process.
2025-06-16T15:30:09.182+02:00 DEBUG 38992 --- [ntextThread-194] s.s.i.WebsocketLoggingChannelInterceptor : WS >> DISCONNECT from "781bb818-9081-4dcb-8bc5-368d7808ea20"
2025-06-16T15:30:09.183+02:00 DEBUG 38992 --- [ntextThread-198] o.s.w.s.m.SubProtocolWebSocketHandler    : No session for GenericMessage [payload=byte[0], headers={simpMessageType=DISCONNECT_ACK, simpDisconnectMessage=GenericMessage [payload=byte[0], headers={simpMessageType=DISCONNECT, stompCommand=DISCONNECT, simpSessionAttributes={org.springframework.messaging.simp.SimpAttributes.COMPLETED=true}, simpUser=CustomJwtAuth[Principal=xxx.core.security.model.CustomUserDetails@6ef1deb6, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[...]], simpSessionId=7ff0a794-acd6-312f-3032-22f6206b3e60}], simpUser=CustomJwtAuth [Principal=xxx.core.security.model.CustomUserDetails@6ef1deb6, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[...]], simpSessionId=7ff0a794-acd6-312f-3032-22f6206b3e60}]

Now the strange thing is: When I only enable the Spring Boot backend to send PONG messages and disable client heartbeats, this does not happen. The WS connection stays stable.

Why is that?

EDIT: setting the client heartbeat interval to for example 60s results in the same result, it just takes longer: After 3 heartbeats, the server terminates the ws connection with 1002 error code.

See below picture

Image

EDIT 2: Spring Version: 3.4.4