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

Comment From: rstoyanchev

Could you register an error handler on StompEndpointRegistry and get the full details of the exception?

It sounds like the heartbeats are not actually getting to the SimpleBrokerMessageHander, and it is terminating the session after not getting any messages (heartbeat or other) for about 3 times the heartbeat value. Perhaps you have a security interceptor that only allows messages of a certain type?

Comment From: spring-projects-issues

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Comment From: Walnussbaer

Could you register an error handler on StompEndpointRegistry and get the full details of the exception?

It sounds like the heartbeats are not actually getting to the SimpleBrokerMessageHander, and it is terminating the session after not getting any messages (heartbeat or other) for about 3 times the heartbeat value. Perhaps you have a security interceptor that only allows messages of a certain type?

Thank you for looking into it. I did not response earlier because I discarded the appraoch of exluding Sock Js from our WS stack (which is why I was fidelling around with our websocket configs/ the heartbeats in the first place). Using the .withSockJs() command on the StompEndpointRegistry, every thing works as expected. But since the SockJsheartbeats are different, this does not really bother me.

Back to topic: I added the following to your StompEndpointRegistry: registry.setErrorHandler(new StompSubProtocolErrorHandler());

This is the debugger view when the handleErrorMessageToClient gets triggered after the 3 mentioned heartbeats:

Image

By the way, none of the heartbeats sent by the client pass any of your defined ChannelInterceptors. That's not normal isn't it? I was looking into it with the debugger, no breakpoint is triggered inside the preSend method of the ChannelInterceptor