At the moment, I find no equivalent of WebClient's toBodilessEntity within WebTestClient.

Use-case: Simply inspect that response is returned when its content cannot be inspected or it makes no sense for it to be inspected.

I have the following workaround which requires a lot of boilerplate

var res = client.mutate()
        .responseTimeout(Duration.ofSeconds(10))
        .build()
        .get()
        .uri("download")
        .exchange()
        .expectStatus()
        .isOk();

res.returnResult(DataBuffer.class).getResponseBody().map(DataBufferUtils::release).blockLast();

If I use any other variant, Netty Leak Detector will signal buffer leaks. One such example is as follows

var body = client.mutate()
          .responseTimeout(Duration.ofSeconds(10))
          .build()
          .get()
          .uri("download")
          .exchange()
          .expectStatus()
          .isOk()
          .returnResult()
          .getResponseBodyContent();

assertThat(body).isNotEmpty();

Example above results in netty buffer leaks

2025-12-02T16:32:53.192+01:00 ERROR 11676 --- [demo] [flux-http-nio-3] io.netty.util.ResourceLeakDetector       : LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records: 
#1:
    io.netty.handler.codec.http.DefaultHttpContent.release(DefaultHttpContent.java:92)
    io.netty.util.ReferenceCountUtil.release(ReferenceCountUtil.java:90)
    io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:93)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
    io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:434)
    io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
    io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:318)
    io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
    io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
    io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
    io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:168)
    io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
    io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:388)
    io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:596)
    io.netty.channel.nio.NioIoHandler.processSelectedKeysPlain(NioIoHandler.java:541)
    io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:514)
    io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:484)
    io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:225)
    io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:196)
    io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1193)
    io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    java.base/java.lang.Thread.run(Thread.java:1474)
#2:
    Hint: 'reactor.right.reactiveBridge' will handle the message from this point.
    io.netty.handler.codec.http.DefaultHttpContent.touch(DefaultHttpContent.java:86)
    io.netty.handler.codec.http.DefaultHttpContent.touch(DefaultHttpContent.java:25)
    io.netty.channel.DefaultChannelPipeline.touch(DefaultChannelPipeline.java:115)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:343)
    io.netty.handler.codec.http.HttpContentDecoder.decode(HttpContentDecoder.java:170)
    io.netty.handler.codec.http.HttpContentDecoder.decode(HttpContentDecoder.java:48)
    io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:91)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
    io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:434)
    io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
    io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:333)
    io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:455)
    io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:290)
    io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
    io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
    io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
    io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:168)
    io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
    io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:388)
    io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:596)
    io.netty.channel.nio.NioIoHandler.processSelectedKeysPlain(NioIoHandler.java:541)
    io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:514)
    io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:484)
    io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:225)
    io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:196)
    io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1193)
    io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    java.base/java.lang.Thread.run(Thread.java:1474)
#3:
    org.springframework.core.io.buffer.NettyDataBufferFactory.wrap(NettyDataBufferFactory.java:94)
    org.springframework.http.client.reactive.ReactorClientHttpResponse.lambda$getBody$1(ReactorClientHttpResponse.java:110)
    reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:106)
    reactor.core.publisher.FluxPeek$PeekSubscriber.onNext(FluxPeek.java:203)
    reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:122)
    reactor.netty.channel.FluxReceive.onInboundNext(FluxReceive.java:383)
    reactor.netty.channel.ChannelOperations.onInboundNext(ChannelOperations.java:445)
    reactor.netty.http.client.HttpClientOperations.onInboundNext(HttpClientOperations.java:946)
    reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:115)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
    io.netty.handler.codec.http.HttpContentDecoder.decode(HttpContentDecoder.java:170)
    io.netty.handler.codec.http.HttpContentDecoder.decode(HttpContentDecoder.java:48)
    io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:91)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
    io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:434)
    io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
    io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:333)
    io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:455)
    io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:290)
    io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
    io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
    io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
    io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:168)
    io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
    io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:388)
    io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:596)
    io.netty.channel.nio.NioIoHandler.processSelectedKeysPlain(NioIoHandler.java:541)
    io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:514)
    io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:484)
    io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:225)
    io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:196)
    io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1193)
    io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    java.base/java.lang.Thread.run(Thread.java:1474)
#4:
    io.netty.buffer.AdvancedLeakAwareByteBuf.skipBytes(AdvancedLeakAwareByteBuf.java:539)
    io.netty.handler.codec.http.HttpObjectDecoder.decode(HttpObjectDecoder.java:518)
    io.netty.handler.codec.http.HttpClientCodec$Decoder.decode(HttpClientCodec.java:320)
    io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:530)
    io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:469)
    io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:290)
    io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
    io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
    io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
    io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
    io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:168)
    io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
    io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:388)
    io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:596)
    io.netty.channel.nio.NioIoHandler.processSelectedKeysPlain(NioIoHandler.java:541)
    io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:514)
    io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:484)
    io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:225)
    io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:196)
    io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1193)
    io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    java.base/java.lang.Thread.run(Thread.java:1474)

Full example can be found at https://github.com/krezovic/spring-issues/tree/webtestclient-body-leak

Run with ./mvnw clean verify

Comment From: bclozel

@krezovic Thanks for raising this.

First, a couple of side remarks. You can configure the leak detection in your tests without configuring surefire for all tests. You can annotate your tests with custom properties:

@AutoConfigureWebTestClient
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.netty.leak-detection=paranoid")
class DemoApplicationTests {

I think there are several improvements that we can make when it comes to memory management with WebTestClient. At this stage, I'm not sure if they should be documentation or code enhancements, or both.

consuming the response body with returnResult

For the first code snippet, I have tried the following and this consumes and releases the data buffers as it should:

var res = client.mutate()
        .responseTimeout(Duration.ofSeconds(10))
        .build()
        .get()
        .uri("download")
        .exchange()
        .expectStatus().isOk()
        .returnResult(Void.class); // this should consume the response body

Would this approach work for you? If so, we can probably document this a bit better in our reference docs and/or Javadoc.

the special case of getResponseBodyContent

As for this second code snippet, getResponseBodyContent is indeed a special case where you can get the response content without actually consuming the Flux response body. If we release buffers as they are written in the WiretapConnector internal buffer, this does solve this issue but will cause IllegalReferenceCountException because we are releasing buffers twice (once when buffering the response content, and another time when actually consuming the response body).

I'm not sure if we should better document this behavior, warn developers in general, or find a way to revisit the connector to consume the body if it hasn't been consumed already.

I'll discuss this with the team.

Comment From: krezovic

Hello @bclozel,

Thanks for the hint about spring.netty.leak-detection=paranoid, it has been a while since I visited the property appendix page to figure out this one. I was simply following Netty documentation.

The returnResult(Void.class) is indeed simpler than my original solution, although not really obvious. I'd still prefer a concrete method that does "I acknowledge there is a body, but I do not want/need to inspect it".