The Problem

Using Springframework v6.2.6 (not spring boot) and Tomcat v11.0.6 (not embedded) I cannot use the ShallowEtagHeaderFiler on a resource that is internally forwarded. I have several url paths that should result in the index.html page being served (single page application style), and I use server-side forwarding so the url displayed to the user remains the same. After the forward happens, the response content is missing (blank page), and the ETag header isn't present, despite a 200 status code. I also see this in the tomcat access log

org.apache.catalina.core.ApplicationDispatcher.forward Disabling the response for further output

Specifically, I think this happens when a HttpServletResponseWrapper (like what is used by ShallowEtagHeaderFilter) is active in the filter chain and I perform a RequestDispatcher.forward() (e.g., via Spring MVC's InternalResourceViewResolver) to a resource. After the forward, the output can't be written to for the Etag itself, and also for the contents that were cached.

Forwarding works as expected using Spring v5.3.27 and Tomcat 9.0.102

Sample Setup

TestController

@Controller
public class TestController {
    @RequestMaping(value = {"/", "/admin/, "/admin", "/dev", "/dev/"})
    public String handleRootRequest() {
       return "/resources/index.html";
    }
}

MvcConfiguration

@Configuration
@EnableWebMvc
public class TestMvcConfiguration implements WebMvcConfigurer

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**")
            .addResourceLocations("/resources/")
            .setCachePeriod(0);
    }

    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .favorParmeter(true)
            .useRegisteredExtensionsOnly(true)
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("html", MediaType.TEXT_HTMNL);
     }

     // Have tried this instead of the controller endpoint, but same result
     // @override
    // public void addViewControllers(ViewControllerRegistry registry) {
    //    registry.addViewController("/").setViewName("forward:/resources/index.html");
    // }

Snippet of applicable WebApplicationInitializer

public class WebApInitializer implements WebApplicationInitializer {

    @Override
     public void onStartup(ServletContext servletContext) throws ServletException {
         try (AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
              AnnotationConfigWebApplicationContext dispatcherContext = new AnnotationConfigWebApplicationContext(); ) {
              rootContext.register(AppConfig.class);
              servletContext.addListener(new ContextLoaderListener(rootContext));

               // filters, but the etag filer is the last one
              FilterRegistration.Dynamic etagFilter = servletContext.addFilter("shallowEtagHeaderFilter", new ShallowETagHeaderFilter());
               etagFilter.setAsyncSupported(true);
               etagFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ASYNC), true, "/*");

                dispatcherContext.register(MvcConfig.class);
                DispatcherServlet dispatcherSrv = new DispatcherServlet(dispatcherContext);
                ServletRegistration.Dynamic dispatcher = servletContext.addServlet("dispatcher", dispatcherSrv);
                dispatcher.addLoadOnStartup(1);
                dispatcher.addMapping("/");
                dispatcher.setAsyncSupported(true);
          }
     }
}

Create a generic ApplicationConfiguration Create a generic index.html file and put it in src/main/webapps/resources. Create a war file and deploy it to Tomcat. Attempt to go to localhost:8080/testApp (or whatever the root context of the war file is) You won't get the contents of the index.html, only a blank page.

Workaround

If I stream my index.html file directly from the endpoint in the Controller, thereby bypassing the forwarding by the mvc internal resource handler, the content of the index.html appears and the Etag header is present The following code in the Controller (the MvcConfiguration code is being bypassed) works

@Controller
public class TestController {
    @RequestMaping(value = {"/", "/admin/, "/admin", "/dev", "/dev/"})
    public void handleRootRequest(HttpServletRequest request, HttpServletResponse response) {
        response.setContentType(MediaType.TEXT_HTML_VALUE);
        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0");
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);

        ServletContext servletContext = request.getServletContext();
        String indexPath = "/resources/index.html";
        InputStream is = servletContext.getResourceAsStream(indexPath);

        if (is != null) {
            try (OutputStream os = response.getOutputStream()) {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = is.read(buffer)) != -1) {
                    os.write(buffer, 0, bytesRead);
                }
            } finally {
                is.close(); 
            }
        } else {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
        }
    }
}

Comment From: bclozel

I can't reproduce the behavior you're describing. See this sample: https://github.com/bclozel/gh-34999/tree/main

I'm closing this issue but we can reopen if you can provide a minimal sample application that reproduces the problem. You can start from the repository I've provided and submit a PR there.

You can use the following command to run the project: ./gradlew assemble && java -jar build/libs/gh-34999-0.0.1-SNAPSHOT.war and see that:

➜  gh-34999 git:(main) http localhost:8080/
HTTP/1.1 200
Connection: keep-alive
Content-Language: en-FR
Content-Length: 128
Content-Type: text/html;charset=UTF-8
Date: Tue, 10 Jun 2025 17:00:45 GMT
ETag: "0cf7099aa335b3c131cb7d6d0aa77e7f1"
Keep-Alive: timeout=60
Set-Cookie: JSESSIONID=CA6FDF0E64BDCE8D5D6F9C2A0AC27BBA; Path=/; HttpOnly

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
HELLO
</body>
</html>

Thanks!

Comment From: cmitchell

Thanks for the sample project, but I don't think it's an accurate representation of the issue. The issue arises with a spring-mvc application deployed as a war file (no spring boot and not in an embedded tomcat.) If I provide a sample project, it would still require deploying the resulting war to a standalone Tomcat v 11.0.6.

Comment From: bclozel

The InternalResourceResolver and ShallowETagFilter work the same in both modes so I guess this means this is a setup issue in your application.

Comment From: cmitchell

Thanks. While going through the InternalResourceViewResolver source code, I saw

public void setAlwaysInclude(boolean alwaysInclude)
Specify whether to always include the view rather than forward to it.
Default is "false". Switch this flag on to enforce the use of a Servlet include, even if a forward would be possible.

I set that to true, and that allowed me to stop streaming my html resource directly from the controller, which makes sense since setting that to true means I'm no longer using the forward directive and that's what causes the issue.

While I understand the files in question are the same between a spring boot and an mvc application, I think their interaction with Tomcat is what's at play in this issue. (which was why I specified the Tomcat version.) I'll submit a bug report to Tomcat if you genuinely think it's on their end. I just know that I did not have to use setAlwaysIncluded(true) and the default of forward worked in Spring v5.3.27 (mvc) and Tomcat (standalone) 9.0.102 so one of those two changed this behavior. (Admittedly, there's a wide gap of versions that it could've happened between spring 5.3.27 and 6.2.6 and Tomcat 9.0.102 and 11.0.6)

Using Include rather than Forward works for me in case others run into this issue before spring or tomcat has addressed it.

Comment From: bclozel

I can’t assess the situation unless I can reproduce the problem. As far as I am concerned this could be an issue with the JSP itself or another filter.

Comment From: cmitchell

Understood. I gave you the relevant bits, but if I find time I'll stand up a repo. By the way, I'm using spring-mvc, but not JSP - not every application can be a boot application, but it doesn't automatically make it JSP based.

Comment From: bclozel

By the way, I'm using spring-mvc, but not JSP - not every application can be a boot application, but it doesn't automatically make it JSP based.

I don't understand. To me JSPs would exercise even more forward dispatches and the response outputstream. Of course JSPs are less fashionable these days, just like WAR deployment. It doesn't mean we don't support those.

It's getting late here but I'lol update my sample tomorrow to use a static resource instead.