namalzi

← All posts

Digging into Tomcat's Source 9: The Filter Chain and the Servlet Call — Until service() Runs

Tomcat · Published

At the very end of the valve chain, Tomcat takes out a servlet instance, picks the filters that match this request, puts them in an array, and walks that array one step at a time until it finally calls service(). We follow this in the source, and use examples to check when a servlet's init() runs, how filter order is decided, how forward differs from include, and how an exception becomes an error page.

What this post covers

This is the ninth post in the "Digging into Tomcat's Source" series. In Part 8 we saw a request pass through the Engine → Host → Context → Wrapper pipelines, going through valves along the way. This time we start at the very end of that chain: StandardWrapperValve, the basic valve of the Wrapper. This is where our own code — filters and servlets — finally gets called. In an application built on Spring MVC, the servlet called here is the DispatcherServlet from Spring Part 21.

We want to answer four questions.

Item Details
Project Apache Tomcat
Version 12.0.0-M1 (main, development build 12.0.0-M1-dev)
Commit 17aa1769b0 (2026-10-23)
Main classes core/StandardWrapperValve, core/StandardWrapper, core/ApplicationFilterFactory, core/ApplicationFilterChain, core/ApplicationDispatcher, core/StandardHostValve

All line numbers in this post refer to this commit. Part 1 shows how to get the same source and build the Docker image used for the examples. The code quoted here is identical on the widely used stable 11.0.x branch (checked by comparing the files; StandardContext differs in places this post does not quote, so its line numbers are shifted on 11.0.x, and the one difference that touches this post is listed under "What this post leaves out").

ApplicationDispatcher alone is over 800 lines, and much of it is branching for async processing. This post picks only the main path that a first-time reader can follow.

Terms to know first

A Wrapper is the innermost of Tomcat's four containers, and it looks after one servlet definition. One <servlet> in web.xml, or one @WebServlet, becomes one StandardWrapper object. The container hierarchy was covered in Part 2, and how a request URL picks a Wrapper in Part 7. This post starts after the Wrapper has been chosen.

Valves and the basic valve. Every container has a pipeline, and every pipeline ends with a basic valve. The basic valve's job is to hand the request to the next container down. A Wrapper has no container below it, so its basic valve, StandardWrapperValve, calls the filters and the servlet instead (Part 8).

A filter is an object that implements the servlet specification's jakarta.servlet.Filter interface. It sits in front of a servlet and can look at or change the request and response — setting the character encoding, authentication and logging are typical uses. A filter does its work in doFilter(request, response, chain) and then calls chain.doFilter(request, response) to pass things on. That chain is the filter chain (FilterChain), and Tomcat's implementation is ApplicationFilterChain. Base classes that Spring built on this interface, such as OncePerRequestFilter, were covered in Spring Part 20.

The dispatcher type (DispatcherType) says how a request reached the servlet. There are five: REQUEST (straight from the client), FORWARD and INCLUDE (handed over by another servlet), ERROR (sent to an error page), and ASYNC (back after async processing). A filter mapping's <dispatcher> is matched against this value.

forward and include. Inside a servlet you can get a RequestDispatcher with request.getRequestDispatcher("/other-path") and use it to pass the request to another servlet. forward() means "you produce the whole response"; include() means "put your output into the middle of my response". Both happen inside the server, so the address in the browser does not change.

1. The big picture: from the end of the valve chain to the servlet

The Wrapper's pipeline gets its basic valve in the StandardWrapper constructor.

    public StandardWrapper() {

        super();
        swValve = new StandardWrapperValve();
        pipeline.setBasic(swValve);
        broadcaster = new NotificationBroadcasterSupport();

    }

java/org/apache/catalina/core/StandardWrapper.java lines 83–90

The Context's basic valve, StandardContextValve, takes the Wrapper attached to the request and calls wrapper.getPipeline().getFirst().invoke(request, response) (StandardContextValve.java lines 65–77). If no extra valve was added to the Wrapper, that goes straight to StandardWrapperValve.invoke(). Here is the heart of that method.

        // Allocate a servlet instance to process this request
        try {
            if (!unavailable) {
                servlet = wrapper.allocate();
            }
        } catch (UnavailableException e) {
        // ...
        }

        MessageBytes requestPathMB = request.getRequestPathMB();
        DispatcherType dispatcherType = DispatcherType.REQUEST;
        if (request.getDispatcherType() == DispatcherType.ASYNC) {
            dispatcherType = DispatcherType.ASYNC;
        }
        request.setAttribute(Globals.DISPATCHER_TYPE_ATTR, dispatcherType);
        request.setAttribute(Globals.DISPATCHER_REQUEST_PATH_ATTR, requestPathMB);
        // Create the filter chain for this request
        ApplicationFilterChain filterChain = ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);

        // Call the filter chain for this request
        // NOTE: This also calls the servlet's service() method
        Container container = this.container;
        try {
            if ((servlet != null) && (filterChain != null)) {
                // Swallow output if needed
                if (context.getSwallowOutput()) {
                    // ...
                } else {
                    if (request.isAsyncDispatching()) {
                        request.getAsyncContextInternal().doInternalDispatch();
                    } else {
                        filterChain.doFilter(request.getRequest(), response.getResponse());
                    }
                }

java/org/apache/catalina/core/StandardWrapperValve.java lines 110–166

The path from StandardWrapperValve to the servlet. StandardContextValve calls the first valve of the Wrapper pipeline, which leads to StandardWrapperValve.invoke, the Wrapper's basic valve. It gets the servlet instance with wrapper.allocate (creating it and calling init if needed), builds a chain holding this request's matching filters in an array with ApplicationFilterFactory.createFilterChain, and calls filterChain.doFilter. The chain calls the filters in turn and Servlet.service at the end; with Spring this servlet is DispatcherServlet. If an exception occurs, the exception method stores it in a request attribute, sets the status to 500 and marks the response as an error. Either way, finally calls filterChain.release and wrapper.deallocate. The four blue boxes are everything this valve does. The red box is a path taken only when an exception occurs.

Read in order, it is four steps.

  1. wrapper.allocate() — gets the servlet instance. If there isn't one yet, it is created and init() is called right here (section 2).
  2. Writes the dispatcher type and path into request attributes (lines 131–136). A request straight from the client is REQUEST (only a request coming back from an async dispatch is ASYNC). The next step reads these two attributes when picking filters.
  3. ApplicationFilterFactory.createFilterChain() — picks the filters that match this request and builds a chain (section 3).
  4. filterChain.doFilter() — as the comment says, this one call goes through the filters all the way to the servlet's service() (section 4). The object passed in is request.getRequest(), the RequestFacade from Part 7. Application code never sees Tomcat's internal Request, only this shell.

When it is done, the finally block empties the chain (filterChain.release()) and gives the servlet instance back (wrapper.deallocate(), lines 217–225). "Gives back" is a strong word: today deallocate() only decrements a usage counter (StandardWrapper.java lines 622–625). A single instance is shared by every request.

Example 1: the call stack when service() is reached

To check that requests really take this path, I registered three filters (A, B, C) and one servlet in web.xml and printed the call stack from inside the servlet. Each filter prints -> name on the way in and <- name on the way out.

This example was run in a Docker container (Eclipse Temurin JDK 21) with Tomcat 12.0.0-M1-dev built unchanged from the base commit (17aa1769b0). The example folder is ex/u09-filter-chain. Its run.sh compiles the classes with javac, puts them in $CATALINA_BASE/webapps/u09, starts Tomcat with catalina.sh start, sends requests with curl, and shows only the lines the example printed into catalina.out.

// Example written for this post (not Tomcat source code)
package ex.u09;

// ... imports omitted ...

/** A filter that prints "-> name" on the way in and "<- name" on the way out. The same class is registered several times under different names. */
public class LogFilter implements Filter {

    private String name;

    @Override
    public void init(FilterConfig config) {
        name = config.getFilterName();
        System.out.println("[u09] filter init: " + name);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        System.out.println("[u09]   -> " + name);
        chain.doFilter(req, resp);   // the next filter, or the servlet if this is the last one
        System.out.println("[u09]   <- " + name);
    }
}

[From the example ex/u09-filter-chain/src/ex/u09/LogFilter.java, imports omitted]

When a request has ?stack, the servlet (TraceServlet) takes new Throwable().getStackTrace(), keeps only the lines from org.apache.catalina, jakarta.servlet and the example package, and prints them up to CoyoteAdapter. Here is the result (with the leading [u09] removed).

===== GET /hello?stack =====
  -> A
  -> C
  -> B
    servlet hello doGet, instance @50cab0f2
      at ex.u09.TraceServlet.doGet(TraceServlet.java:23)
      at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
      at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
      at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
      at ex.u09.LogFilter.doFilter(LogFilter.java:22)
      at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
      at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
      at ex.u09.LogFilter.doFilter(LogFilter.java:22)
      at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
      at ex.u09.LogFilter.doFilter(LogFilter.java:22)
      at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
      at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
      at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77)
      at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540)
      at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
      at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:82)
      at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:777)
      at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:71)
      at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:347)
  <- B
  <- C
  <- A

The instance @… value changes on every run. Read from the bottom up:

Easy to get wrong: if a filter is slow, the servlet waits too

<- B, <- C and <- A were printed after the servlet finished. A filter's chain.doFilter() only returns once everything down to the servlet is done, and all of this happens inside one call stack on one thread (the http-nio-8080-exec-N from Part 5). That is why timing a request in a filter includes the servlet's time, and why the servlet is never called at all if some filter doesn't call chain.doFilter().

2. The servlet instance and init() — allocate() and load-on-startup

allocate(): created the first time it is needed

        // Load and initialize our instance if necessary
        if (instance == null || !instanceInitialized) {
            synchronized (this) {
                if (instance == null) {
                    try {
                        if (log.isTraceEnabled()) {
                            log.trace("Allocating instance");
                        }
                        instance = loadServlet();
                        newInstance = true;
                        // Increment here to prevent a race condition
                        // with unload. Bug 43683, test case #3
                        countAllocated.incrementAndGet();
                    } catch (ServletException e) {
                        throw e;
                    } catch (Throwable t) {
                        ExceptionUtils.handleThrowable(t);
                        throw new ServletException(sm.getString("standardWrapper.allocate"), t);
                    }
                }
                if (!instanceInitialized) {
                    initServlet(instance);
                }
            }
        }

java/org/apache/catalina/core/StandardWrapper.java lines 584–608

load-on-startup: created early, when the Context starts

<load-on-startup> in web.xml (or @WebServlet(loadOnStartup = …)) moves that "first time it is needed" up to the moment the Context starts. The default is -1 (StandardWrapper.java line 135). This is the end of StandardContext.startInternal():

            // Configure and call application filters
            if (ok) {
                if (!filterStart()) {
                    log.error(sm.getString("standardContext.filterFail"));
                    ok = false;
                }
            }

            // Load and initialize all "load on startup" servlets
            if (ok) {
                if (!loadOnStartup(findChildren())) {
                    log.error(sm.getString("standardContext.servletFail"));
                    ok = false;
                }
            }

java/org/apache/catalina/core/StandardContext.java lines 4642–4656

Just before this, the ServletContainerInitializers are called (lines 4605–4614) and then the ServletContextListeners (lines 4616–4622). That is where Spring's SpringServletContainerInitializer and ContextLoaderListener get called (section 5 of Spring Part 20). The full startup order of a Context is the topic of Part 10.

        // Collect "load on startup" servlets that need to be initialized
        TreeMap<Integer,ArrayList<Wrapper>> map = new TreeMap<>();
        for (Container child : children) {
            Wrapper wrapper = (Wrapper) child;
            int loadOnStartup = wrapper.getLoadOnStartup();
            if (loadOnStartup < 0) {
                continue;
            }
            Integer key = Integer.valueOf(loadOnStartup);
            map.computeIfAbsent(key, k -> new ArrayList<>()).add(wrapper);
        }

        // Load the collected "load on startup" servlets
        for (ArrayList<Wrapper> list : map.values()) {
            for (Wrapper wrapper : list) {
                try {
                    wrapper.load();

java/org/apache/catalina/core/StandardContext.java lines 4353–4369

Negative values are skipped; values of 0 or more are collected in a TreeMap keyed by the number. A TreeMap keeps its keys sorted, so wrapper.load() is called smallest number first. load() calls loadServlet() and initServlet() — the same work as the first part of allocate() (lines 704–709). If this throws, by default it is only logged and the Context keeps starting (the comment and failCtxIfServletStartFails check at lines 4374–4380).

When filters and servlets are created. In StandardContext.startInternal, ServletContainerInitializer.onStartup runs first (Spring's SpringServletContainerInitializer hooks in here), then listenerStart calls the ServletContextListeners, filterStart creates and inits every defined filter, and loadOnStartup creates and inits only servlets with a value of 0 or more, smallest first. Then the Context has started. When the first request comes in, StandardWrapper.allocate calls loadServlet and init if there is no instance, and from the second request on it returns the same instance. All filters are created at startup; servlets are created at startup only if they have load-on-startup. The yellow box is the "create it later" spot.

Filters are all created at startup

Unlike servlets, filters have no "create on first use". filterStart() goes through every defined filter (filterDefs) and creates a new ApplicationFilterConfig(this, entry.getValue()) (StandardContext.java lines 4037–4046), and that constructor creates the filter object and calls filter.init(this) (ApplicationFilterConfig.java lines 93–100, lines 213–243). When a request comes in, the chain uses these ready-made ApplicationFilterConfigs.

Example 2: when init is called

The web.xml from Example 1 registers the same TraceServlet class three times under different names. hello has no load-on-startup, late has 5, and early has 1. In web.xml, late is written before early. The servlet's init() prints its name and the thread name.

===== startup, then the readiness probe GET /early =====
filter init: A
filter init: B
filter init: C
servlet init: early (thread main)
servlet init: late (thread main)
  -> A
    servlet early doGet, instance @60e7357c
  <- A
===== GET /hello =====
hello from hello
servlet init: hello (thread http-nio-8080-exec-2)
  -> A
  -> C
  -> B
    servlet hello doGet, instance @50cab0f2
  <- B
  <- C
  <- A
===== GET /hello (second time) =====
hello from hello
  -> A
  -> C
  -> B
    servlet hello doGet, instance @50cab0f2
  <- B
  <- C
  <- A

The first group is the startup log plus the log of the GET /early that run.sh sends to check that startup has finished. In each group, hello from … (the response body) appears before the log because run.sh prints the response first and pulls the log lines afterwards. The instance @… values and the exec-2 number change between runs.

Easy to get wrong

3. Picking filters — ApplicationFilterFactory.createFilterChain()

Two passes over the mapping array

        // Add the relevant path-mapped filters to this filter chain
        for (FilterMap filterMap : filterMaps) {
            if (!matchDispatcher(filterMap, dispatcher)) {
                continue;
            }
            if (!FilterUtil.matchFiltersURL(filterMap, requestPath)) {
                continue;
            }
            ApplicationFilterConfig filterConfig =
                    (ApplicationFilterConfig) context.findFilterConfig(filterMap.getFilterName());
            if (filterConfig == null) {
                log.warn(sm.getString("applicationFilterFactory.noFilterConfig", filterMap.getFilterName()));
                continue;
            }
            filterChain.addFilter(filterConfig);
        }

        // Add filters that match on servlet name second
        for (FilterMap filterMap : filterMaps) {
            if (!matchDispatcher(filterMap, dispatcher)) {
                continue;
            }
            if (!matchFiltersServlet(filterMap, servletName)) {
                continue;
            }
            // ... find filterConfig and addFilter, same as above ...
        }

java/org/apache/catalina/core/ApplicationFilterFactory.java lines 98–130

filterMaps is an array holding the <filter-mapping>s in the order they were written in web.xml (context.findFilterMaps(), line 80). It is scanned twice.

  1. The first pass picks only mappings whose URL pattern (<url-pattern>) matches.
  2. The second pass picks only mappings whose servlet name (<servlet-name>) matches.

Both passes first check the dispatcher type with matchDispatcher(). A mapping with no <dispatcher> at all counts as REQUEST (FilterMap.java lines 233–241). So a filter registered the ordinary way is not called for requests that arrive by forward or for error pages (see the examples in sections 5 and 6).

URL patterns are checked by FilterUtil.matchFiltersURL(): exact match → /path/* → *.extension → empty string (the context root) (FilterUtil.java lines 76–124). The rules look like servlet mapping (Part 7), but as the source comment points out, for filters "any match is enough", so order doesn't matter (lines 83–84). Servlet mapping picks exactly one; filter mapping picks every match.

The same filter only once

addFilter() checks whether the filter is already there before adding it.

    void addFilter(ApplicationFilterConfig filterConfig) {

        // Prevent the same filter being added multiple times
        for (int i = 0; i < n; i++) {
            if (filters[i] == filterConfig) {
                return;
            }
        }

        if (n == filters.length) {
            filters = Arrays.copyOf(filters, n + INCREMENT);
        }
        filters[n++] = filterConfig;

    }

java/org/apache/catalina/core/ApplicationFilterChain.java lines 174–188

Inside, the chain is just an array (filters) and a count (n). When it runs out of room, it grows by 10 slots (INCREMENT, line 53).

Example 1 again: why A → C → B?

The example's mappings were written like this.

  <!-- servlet-name mapping (B) deliberately written first -->
  <filter-mapping><filter-name>B</filter-name><servlet-name>hello</servlet-name></filter-mapping>
  <filter-mapping><filter-name>A</filter-name><url-pattern>/*</url-pattern></filter-mapping>
  <filter-mapping><filter-name>C</filter-name><url-pattern>/hello</url-pattern></filter-mapping>
  <!-- map A once more, by servlet-name -->
  <filter-mapping><filter-name>A</filter-name><servlet-name>hello</servlet-name></filter-mapping>

[From the example ex/u09-filter-chain/web.xml]

For a request to /hello, the first pass adds the URL-pattern mappings A (/*) and C (/hello) in the order they were written. The second pass looks at the servlet-name mappings B and then A, but A is already there, so addFilter() skips it. The resulting array is [A, C, B] — exactly the example's output. A request to /early with the same web.xml only got A (the first group of Example 2), because C is mapped only to /hello and B only to the servlet hello.

The two passes createFilterChain() makes over example 1's web.xml. The filterMaps array, in web.xml order, is [0] B (servlet-name hello), [1] A (url-pattern /*), [2] C (url-pattern /hello) and [3] A (servlet-name hello). The first pass looks only at URL-pattern mappings, so for a /hello request it adds [1] A and [2] C and skips the servlet-name mappings [0] and [3]. The second pass looks only at servlet-name mappings, so it adds [0] B, and addFilter() skips [3] A because A is already there. The chain's filters array becomes [A, C, B], the same order as example 1's output. B is written first in web.xml yet ends up last; the second pass is the reason.

Easy to get wrong

4. ApplicationFilterChain.doFilter() — recursion over an array

This is the central method of this post. It's not long, so here it is in full.

    @Override
    public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
        // Call the next filter if there is one
        if (pos < n) {
            ApplicationFilterConfig filterConfig = filters[pos++];
            try {
                Filter filter = filterConfig.getFilter();

                if (request.isAsyncSupported() && !(filterConfig.getFilterDef().getAsyncSupportedBoolean())) {
                    request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
                }
                filter.doFilter(request, response, this);
            } catch (IOException | ServletException | RuntimeException e) {
                throw e;
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                throw new ServletException(sm.getString("filterChain.filter"), t);
            }
            return;
        }

        // We fell off the end of the chain -- call the servlet instance
        try {
            if (dispatcherWrapsSameObject) {
                lastServicedRequest.set(request);
                lastServicedResponse.set(response);
            }

            if (request.isAsyncSupported() && !servletSupportsAsync) {
                request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
            }
            // Use potentially wrapped request from this point
            servlet.service(request, response);
        } catch (IOException | ServletException | RuntimeException e) {
            throw e;
        } catch (Throwable t) {
            ExceptionUtils.handleThrowable(t);
            throw new ServletException(sm.getString("filterChain.servlet"), t);
        } finally {
            if (dispatcherWrapsSameObject) {
                lastServicedRequest.set(null);
                lastServicedResponse.set(null);
            }
        }
    }

java/org/apache/catalina/core/ApplicationFilterChain.java lines 100–144

Recursion in ApplicationFilterChain.doFilter. StandardWrapperValve calls chain.doFilter; since pos=0 is less than n=3, it calls filter A.doFilter, and A does its before-work and calls chain.doFilter again. At pos=1 filter C is called, at pos=2 filter B, and when B calls chain.doFilter, pos=3 means there are no filters left, so servlet.service is called. When the servlet returns, the calls unwind through B, C and A, each doing its after-work. A single chain object holds pos, n and the filters array, and remembers the position. The yellow box is the only place that knows who comes next. The filters just call the chain again.

The way chain.doFilter() knows what comes "next" is a single field: pos.

  1. If pos < n, it takes filters[pos], moves pos up by one (pos++), and calls that filter's doFilter(request, response, this). The third argument is the chain itself.
  2. When the filter has done its work and calls chain.doFilter(), we re-enter the same method. pos has already moved up, so the next filter gets called.
  3. Once all filters are used up (pos == n), execution falls through to the bottom and calls servlet.service(). This is the spot the comment calls "We fell off the end of the chain".
  4. When service() finishes, the calls return one by one in the reverse order they were stacked up. The line after a filter's chain.doFilter() runs at this point.

The key design point is that "after the last filter, call the servlet" lives in the chain, not in the filters. A filter doesn't need to know whether more filters follow it, or which servlet is at the end. That's why the same filter class can be put in front of any servlet.

Pass a wrapper, and everything after gets the wrapper

If a filter passes a different object, as in chain.doFilter(new MyRequestWrapper(req), resp), every filter and servlet after it receives that object, because the chain passes along whatever arguments it is given (lines 111 and 132, "Use potentially wrapped request from this point"). Wrappers that let you read a request body more than once, or that change headers, work this way.

Exceptions are rethrown as they are

IOException, ServletException and RuntimeException thrown by a filter or servlet are caught and immediately rethrown unchanged (lines 112–113 and 133–134). Only other Throwables (such as an Error) are wrapped in a ServletException. The receiver is the StandardWrapperValve from section 1; section 6 picks it up from there.

Easy to get wrong

5. RequestDispatcher — forward and include

Calling req.getRequestDispatcher("/target") inside a servlet returns an ApplicationDispatcher. The Wrapper for the target path is looked up right then, using the Mapper from Part 7 (ApplicationContext.java line 421, line 437). forward() and include() both end up in the same invoke().

        // Get the FilterChain Here
        ApplicationFilterChain filterChain = ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);

        // Call the service() method for the allocated servlet instance
        try {
            // for includes/forwards
            if (servlet != null && filterChain != null) {
                filterChain.doFilter(request, response);
            }

java/org/apache/catalina/core/ApplicationDispatcher.java lines 505–513

Just before this (lines 488–503) it gets the target servlet with wrapper.allocate(), and here it builds a new filter chain and calls it — a miniature StandardWrapperValve.invoke(). The difference is that no valves are involved. Valves hang on a container's pipeline, and a forward calls the chain directly, outside any pipeline. That's why the access log valve does not record a forward separately. The request passed in here is a wrapper made by Tomcat, not the internal Request, so createFilterChain() makes a new chain instead of reusing one (ApplicationFilterFactory.java lines 69–72).

forward — hand over the whole response

    public void forward(ServletRequest request, ServletResponse response) throws ServletException, IOException {
        // Reset any output that has been buffered, but keep headers/cookies
        if (response.isCommitted()) {
            throw new IllegalStateException(sm.getString("applicationDispatcher.forward.ise"));
        }
        response.resetBuffer();

java/org/apache/catalina/core/ApplicationDispatcher.java lines 204–209

  1. An already committed response throws an exception. "Committed" means the status line and headers have already gone out to the client. After that, "let another servlet produce the whole response" is no longer possible.
  2. The body written to the buffer is discarded, but headers and cookies stay (resetBuffer()).
  3. The request is wrapped in an ApplicationHttpRequest whose path is changed to the target's, and the original path is kept in attributes such as jakarta.servlet.forward.request_uri (lines 239–261).
  4. processRequest() sets the dispatcher type to FORWARD and calls invoke() (lines 332–335).
  5. When it returns, the response is finished: ResponseFacade.finish() blocks any further writing (lines 277–290). The source comment notes "This is not a real close in order to support error processing" — the socket isn't really closed (line 272).

include — borrow only the output

include() wraps the response in an ApplicationHttpResponse marked included (line 376, line 751). This wrapper ignores every method that would change the status code or headers.

    @Override
    public void setStatus(int sc) {

        if (!included) {
            ((HttpServletResponse) getResponse()).setStatus(sc);

java/org/apache/catalina/core/ApplicationHttpResponse.java lines 361–365

setHeader(), addCookie(), sendRedirect(), sendError() and the rest are all blocked by the same if (!included) (lines 159–365 of the same file). The request's path is not changed. The target path only goes into attributes such as jakarta.servlet.include.request_uri (lines 386–408), the dispatcher type is set to INCLUDE, and invoke() is called (lines 411–413). Unlike forward, the response is not finished afterwards, so the calling servlet can keep writing.

Comparing forward and include. Servlet front gets /target with req.getRequestDispatcher. forward throws IllegalStateException if the response is committed; otherwise resetBuffer discards only the body and keeps the headers. It wraps the request so the path becomes /target and puts the original path in forward attributes. include wraps the response as included so setStatus and setHeader are ignored, leaves the path unchanged and puts the target path in include attributes. Both go through ApplicationDispatcher.invoke: allocate, a new filter chain, doFilter. After forward the response is closed and front's remaining output is dropped; after include, front keeps writing. The invoke() in the middle is the same; what happens before and after it differs.

Example 3: forward vs include, and filters per dispatcher type

In ex/u09-dispatch, the servlet front writes front: before to the body and sets an X-Front header, then forwards or includes /target, and writes front: after when it gets control back. target sets the status to 202, adds an X-Target header and writes target: body. Four filters use the same class with different <dispatcher> settings (onRequest has no dispatcher; onForward, onInclude and onError have FORWARD, INCLUDE and ERROR). The response shows only the status line, X- headers and body; the log comes below it.

===== GET /front?mode=forward =====
HTTP/1.1 202 
X-Front: yes
X-Target: yes
target: body
filter onRequest: REQUEST /u09/front
filter onForward: FORWARD /u09/target
target: FORWARD requestURI=/u09/target servletPath=/target forward.request_uri=/u09/front include.request_uri=null
front: back from forward, committed=true
===== GET /front?mode=include =====
HTTP/1.1 200 
X-Front: yes
front: before
target: body
front: after
filter onRequest: REQUEST /u09/front
filter onInclude: INCLUDE /u09/front
target: INCLUDE requestURI=/u09/front servletPath=/front forward.request_uri=null include.request_uri=/u09/target
front: back from include, committed=false

If we print the call stack inside target for the same forward, we can see the chains stacked two deep (printed only down to StandardWrapperValve.invoke).

  at ex.u09.TargetServlet.doGet(TargetServlet.java:19)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:512)
  at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:335)
  at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:264)
  at ex.u09.FrontServlet.doGet(FrontServlet.java:22)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)

The bottom half is the client request's chain (onRequest → front), and the top half is the new chain the forward built (onForward → target). Between them are ApplicationDispatcher.forward() line 264 → processRequest() line 335 → invoke() line 512, and not a single valve. The stack shows plainly that a forward is just a method call made inside front's doGet().

Easy to get wrong

6. Exceptions and error pages

When a servlet throws, the chain rethrows it as we saw in section 4, and StandardWrapperValve receives it. This valve does not rethrow it.

    private void exception(Request request, Response response, Throwable exception, int errorCode) {
        request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, exception);
        response.setStatus(errorCode);
        response.setError();
    }

java/org/apache/catalina/core/StandardWrapperValve.java lines 293–297

The catch blocks just log and call this method (lines 169–215; an ordinary RuntimeException such as IllegalStateException lands in the final catch (Throwable t); only InvalidParameterException, used for bad request parameters, is caught separately and uses its own error code). The exception goes into a request attribute, the status code becomes 500 by default, and the response just gets marked as "error". The work of picking and sending an error page is done by StandardHostValve, quite a way back up the valve chain.

            // Look for (and render if found) an application level error page
            if (response.isErrorReportRequired()) {
                // If an error has occurred that prevents further I/O, don't waste time
                // producing an error report that will never be read
                AtomicBoolean result = new AtomicBoolean(false);
                response.getCoyoteResponse().action(ActionCode.IS_IO_ALLOWED, result);
                if (result.get()) {
                    if (t != null) {
                        throwable(request, response, t);
                    } else {
                        status(request, response);
                    }
                }
            }

java/org/apache/catalina/core/StandardHostValve.java lines 141–154

The Host valve calls the Context pipeline at line 115, and after that returns, it takes the exception out of the request attribute at line 133 and arrives at this code.

How an exception becomes an error page. When a servlet throws or calls sendError, StandardWrapperValve catches the exception, puts it in a request attribute and marks status 500 and setError. When the Context pipeline finishes and returns to the Host valve, StandardHostValve.invoke checks isErrorReportRequired. If there is an exception attribute, throwable looks for an error-page by the exception class and its parent classes, falling back to status. If only sendError was called, status looks it up by status code, and failing that by code 0. If found, custom gets a RequestDispatcher for the location and forwards with dispatcher type ERROR, where only filters mapped to ERROR run. If there is no page, ErrorReportValve's default HTML is sent. The error page is chosen by the Host, not the Wrapper. Instead of being thrown, the exception rides up in a request attribute.

Example 4: how an exception and sendError reach the error page

The same app as Example 3 also has /boom (throws an IllegalStateException) and /missing (calls sendError(404, "nothing here")), and its web.xml sends both <exception-type>java.lang.RuntimeException</exception-type> and <error-code>404</error-code> to /error. The /error servlet prints the attributes the container gave it, and when it comes from /boom it prints the call stack down to StandardHostValve.invoke.

===== GET /missing =====
HTTP/1.1 404 
custom error page, status 404
filter onRequest: REQUEST /u09/missing
missing: sendError(404) returned
filter onError: ERROR /u09/error
error page: ERROR status_code=404 request_uri=/u09/missing exception=null
===== GET /boom =====
HTTP/1.1 500 
custom error page, status 500
filter onRequest: REQUEST /u09/boom
filter onError: ERROR /u09/error
error page: ERROR status_code=500 request_uri=/u09/boom exception=java.lang.IllegalStateException: boom
  at ex.u09.ErrorServlet.doGet(ErrorServlet.java:20)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
  at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:512)
  at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:337)
  at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:264)
  at org.apache.catalina.core.StandardHostValve.custom(StandardHostValve.java:379)
  at org.apache.catalina.core.StandardHostValve.throwable(StandardHostValve.java:266)
  at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:149)
===== SEVERE lines in logs/localhost.*.log =====
org.apache.catalina.core.StandardWrapperValve.invoke Servlet.service() for servlet [boom] in context with path [/u09] threw exception

Easy to get wrong

What this post leaves out

Summary

Concept In one line
StandardWrapperValve The Wrapper's basic valve. allocate → build the chain → doFilter → release and deallocate in finally
StandardWrapper.allocate() One servlet instance per Wrapper. If missing, created and init()ed on the first request
load-on-startup 0 or more means load() at Context startup, smallest number first. Default is -1
Filter init All of them in filterStart(), before servlets. Init order is not guaranteed (HashMap)
createFilterChain() Two passes over the mappings: URL patterns first, servlet names second. Only matching dispatcher types; each filter once
ApplicationFilterChain A filter array and pos. doFilter() calls the next filter, and servlet.service() at the end. Recursive, so it unwinds in reverse
forward() Only before commit. Clears the body, changes the path, calls a new chain, then finishes the response. No valves involved
include() Path unchanged, status and header changes ignored. The caller keeps writing afterwards
Exception handling The Wrapper valve only turns the exception into an attribute. The Host valve finds the <error-page> and forwards as ERROR

What's next

We have followed a request all the way to the servlet. But this post started from "if web.xml says this". Who reads that web.xml and creates the FilterDefs, FilterMaps and StandardWrappers? How are @WebServlet and the default servlets in conf/web.xml merged? What happens when a WAR lands in webapps? That is Part 10: Deploying a Web App — From a WAR to a Running Context. The ServletContainerInitializer we passed by in section 2 shows up again there.

The filter chain has relatives in the other series. Spring MVC's HandlerInterceptor sits before and after the handler inside DispatcherServlet, but instead of recursion where the chain calls the next one, it is a loop that runs preHandle front to back and postHandle back to front (Spring Part 21 section 5, Spring Part 23). So an interceptor stops a request not by "not calling the next one" but by returning false from preHandle. httpd's filters (httpd Part 11) share the name but are a different kind of chain: what flows through them is not request/response objects but chunks of data (bucket brigades). Re-processing an error page through a forward is the same idea as httpd's ErrorDocument re-processing the request through an internal redirect (httpd Part 10 section 6).

More posts