Problem
Enabling virtual threads (spring.threads.virtual.enabled=true) causes a silent behavioral regression for GraphQL HTTP requests: resolvers that previously ran without any timeout are now terminated at exactly 30 seconds, and there is no property or configuration to change this.
Environment
- Spring for GraphQL 1.4.x (confirmed on 1.4.5)
- Spring Boot 3.x with
spring.threads.virtual.enabled=true
- Tomcat (embedded)
Steps to reproduce
- Have a GraphQL resolver that takes > 30 seconds
- Confirm it completes successfully without virtual threads
- Enable
spring.threads.virtual.enabled=true
- Execute the same query — it now times out at exactly 30s
Expected behavior
Enabling virtual threads does not change the timeout behavior of GraphQL HTTP requests. If no timeout was previously applied, none should apply after the switch.
Actual behavior
The request is terminated at 30 seconds. This is Tomcat's Connector.asyncTimeout default, and there is no Spring property to override it.
Root cause
GraphQlHttpHandler.prepareResponse() returns ServerResponse.async(mono.toFuture()) without a timeout (GraphQlHttpHandler.java:119):
return ServerResponse.async(mono.toFuture()); // no timeout argument
When Spring MVC writes this response, DefaultAsyncServerResponse.writeAsync() creates a new AsyncWebRequest and installs it on the WebAsyncManager, replacing the one HandlerFunctionAdapter had already configured. Because no timeout was passed to ServerResponse.async(...), the replacement DeferredResult has a null timeout value, so startDeferredResultProcessing skips the asyncWebRequest.setTimeout(...) call entirely. StandardServletAsyncWebRequest.startAsync() then calls asyncContext.startAsync() without setting any timeout, falling through to Tomcat's Connector.asyncTimeout default of 30 seconds.
The bug only surfaces with truly async dispatch (virtual threads + parallel executor). With synchronous resolvers the CompletableFuture is already complete when writeAsync() runs, so the AsyncContext timer never starts — which is why this was invisible before the switch to virtual threads.
Failing test
The following test demonstrates the bug. MockAsyncContext uses a 10-second default (Tomcat's analog is 30s), and the test shows that even a timeout explicitly configured on HandlerFunctionAdapter does not reach the AsyncContext:
@Test
void asyncRequestTimeoutShouldPropagateToAsyncContext() throws Exception {
CompletableFuture<String> resolverFuture = new CompletableFuture<>();
GraphQlHttpHandler handler = GraphQlSetup.schemaContent("type Query { slow: String }")
.queryFetcher("slow", (env) -> resolverFuture)
.toHttpHandler();
HandlerFunctionAdapter adapter = new HandlerFunctionAdapter();
adapter.setAsyncRequestTimeout(200L);
MockHttpServletRequest servletRequest = createServletRequest("{ slow }", "*/*");
MockHttpServletResponse servletResponse = new MockHttpServletResponse();
ServerRequest serverRequest = ServerRequest.create(servletRequest, MESSAGE_READERS);
servletRequest.setAttribute(RouterFunctions.REQUEST_ATTRIBUTE, serverRequest);
try {
adapter.handle(servletRequest, servletResponse, handler::handleRequest);
AsyncContext asyncContext = servletRequest.getAsyncContext();
assertThat(asyncContext).isNotNull();
// FAILS: actual is 10000 (MockAsyncContext default, analogous to Tomcat's 30000)
// The 200ms configured on HandlerFunctionAdapter was silently dropped.
assertThat(asyncContext.getTimeout()).isEqualTo(200L);
} finally {
resolverFuture.complete("done");
}
}
Workaround
Explicitly bridge the gap at the container level:
@Bean
public WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory> asyncTimeoutTomcatCustomizer(
WebMvcProperties webMvcProperties) {
return factory -> {
Duration timeout = webMvcProperties.getAsync().getRequestTimeout();
if (timeout != null) {
factory.addConnectorCustomizers(c -> c.setAsyncTimeout(timeout.toMillis()));
}
};
}
This requires user code to compensate for a framework gap.
Suggested fix
Two places where this could be addressed:
In spring-graphql: expose a configurable timeout and pass it to ServerResponse.async(future, timeout). This would allow the application to restore the previous no-timeout behavior or set an explicit limit:
return this.asyncRequestTimeout != null
? ServerResponse.async(mono.toFuture(), this.asyncRequestTimeout)
: ServerResponse.async(mono.toFuture());
In Spring Framework (deeper fix): DefaultAsyncServerResponse.writeAsync() should not discard the AsyncWebRequest — and its configured timeout — that HandlerFunctionAdapter already installed on the WebAsyncManager. Reusing the existing request instead of creating a fresh one would fix the issue for all router-function handlers.
Problem
Enabling virtual threads (
spring.threads.virtual.enabled=true) causes a silent behavioral regression for GraphQL HTTP requests: resolvers that previously ran without any timeout are now terminated at exactly 30 seconds, and there is no property or configuration to change this.Environment
spring.threads.virtual.enabled=trueSteps to reproduce
spring.threads.virtual.enabled=trueExpected behavior
Enabling virtual threads does not change the timeout behavior of GraphQL HTTP requests. If no timeout was previously applied, none should apply after the switch.
Actual behavior
The request is terminated at 30 seconds. This is Tomcat's
Connector.asyncTimeoutdefault, and there is no Spring property to override it.Root cause
GraphQlHttpHandler.prepareResponse()returnsServerResponse.async(mono.toFuture())without a timeout (GraphQlHttpHandler.java:119):When Spring MVC writes this response,
DefaultAsyncServerResponse.writeAsync()creates a newAsyncWebRequestand installs it on theWebAsyncManager, replacing the oneHandlerFunctionAdapterhad already configured. Because no timeout was passed toServerResponse.async(...), the replacementDeferredResulthas a null timeout value, sostartDeferredResultProcessingskips theasyncWebRequest.setTimeout(...)call entirely.StandardServletAsyncWebRequest.startAsync()then callsasyncContext.startAsync()without setting any timeout, falling through to Tomcat'sConnector.asyncTimeoutdefault of 30 seconds.The bug only surfaces with truly async dispatch (virtual threads + parallel executor). With synchronous resolvers the
CompletableFutureis already complete whenwriteAsync()runs, so theAsyncContexttimer never starts — which is why this was invisible before the switch to virtual threads.Failing test
The following test demonstrates the bug.
MockAsyncContextuses a 10-second default (Tomcat's analog is 30s), and the test shows that even a timeout explicitly configured onHandlerFunctionAdapterdoes not reach theAsyncContext:Workaround
Explicitly bridge the gap at the container level:
This requires user code to compensate for a framework gap.
Suggested fix
Two places where this could be addressed:
In spring-graphql: expose a configurable timeout and pass it to
ServerResponse.async(future, timeout). This would allow the application to restore the previous no-timeout behavior or set an explicit limit:In Spring Framework (deeper fix):
DefaultAsyncServerResponse.writeAsync()should not discard theAsyncWebRequest— and its configured timeout — thatHandlerFunctionAdapteralready installed on theWebAsyncManager. Reusing the existing request instead of creating a fresh one would fix the issue for all router-function handlers.