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.
- Who creates the servlet object, and when? When is
init()called, and what doesload-on-startupchange? - How are the filters from web.xml picked for each request, in what order are they lined up, and how does
chain.doFilter()know what comes "next"? - What is actually different between
RequestDispatcher.forward()andinclude()in the source? - What path does an exception thrown by a servlet take before it turns into an
<error-page>?
| 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 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.
wrapper.allocate()— gets the servlet instance. If there isn't one yet, it is created andinit()is called right here (section 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 isASYNC). The next step reads these two attributes when picking filters. ApplicationFilterFactory.createFilterChain()— picks the filters that match this request and builds a chain (section 3).filterChain.doFilter()— as the comment says, this one call goes through the filters all the way to the servlet'sservice()(section 4). The object passed in isrequest.getRequest(), theRequestFacadefrom Part 7. Application code never sees Tomcat's internalRequest, 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:
- From
CoyoteAdapter.service()(Part 7), through the Engine, Host and Context valves (Part 8), we reach line 164 ofStandardWrapperValve.invoke()— thefilterChain.doFilter(...)line in the excerpt above. ApplicationFilterChain.doFilter()appears four times. Three are at line 111 (the line that calls a filter), and the last one is at line 132 (the line that calls the servlet). Our filters'doFilter()calls sit in between. The three filters are tied together not by a loop but by calling each other recursively. Section 4 shows this in the source.HttpServlet.service()appears twice. Line 762 is the spec'sservice()that takes aServletRequest; it casts toHttpServletRequestand calls theservice()at line 673, which callsdoGet()because the method is GET.- The filter order is A → C → B, even though B's mapping is written first in web.xml. Section 3 explains why.
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
- There is one instance per Wrapper. The comment on the
instancefield says "The (single) possibly uninitialized instance of this servlet" (lines 120–123). So if you store per-request values in a servlet's fields, concurrent requests will mix them up. - The outer
ifchecks without a lock, and the inner one checks again after takingsynchronized. Even if several first requests arrive at once, only one instance gets created. From the second request on, the outerifis false and no lock is taken at all. loadServlet()creates the object withInstanceManager.newInstance(servletClass)(lines 763–765). It avoids a plainnewso that injections like@Resourceand@PostConstructcan be handled (DefaultInstanceManager.javalines 195–201). TheninitServlet()callsservlet.init(facade)(lines 829–832). TheServletConfigpassed in is not the Wrapper itself but a shell calledStandardWrapperFacade— for the same reason asRequestFacade: to keep the servlet away from Tomcat's internal methods.
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).
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.
- The order is three filter inits → two servlet inits, which matches
filterStart()running beforeloadOnStartup(). early(1) comes beforelate(5) — numeric order, not the order in web.xml.- Both ran on the
mainthread. The Host'sstartStopThreads, used for starting Contexts, defaults to 1, and with 1 the work runs in place without a separate thread (ContainerBase.javaline 244, lines 690–694). hellois initialized on the first request, on the worker thread that received it (http-nio-8080-exec-2). Whoever sends that first request waits for init as well.- The second request has no
servlet init, andinstance @50cab0f2is the same. The same object was reused.
Easy to get wrong
- Filter init order does not follow web.xml.
filterDefsandfilterConfigsareHashMaps (StandardContext.javalines 366, 372). A, B, C came out in order in the example only because the names are single letters and their hash order happened to match. The order filters run in (section 3) is a separate matter from the order they are initialized in. DispatcherServletfollows the same rule. TheHttpServletBean.init()→initWebApplicationContext()from section 2 of Spring Part 21 is, in the end, theservlet.init()that Tomcat calls. Withoutload-on-startup, all that heavy initialization happens on the first request.
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.
- The first pass picks only mappings whose URL pattern (
<url-pattern>) matches. - 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.
B is written first in web.xml yet ends up last; the second pass is the reason.
Easy to get wrong
- URL-pattern mappings always come before servlet-name mappings, no matter where they appear in web.xml. "Filter order = web.xml order" is only true within each kind.
- Filters registered in code may land elsewhere. With the registration object from
ServletContext.addFilter(), callingaddMappingForUrlPatterns(..., isMatchAfter, ...)withisMatchAfterset tofalseputs the mapping before the web.xml mappings (ApplicationFilterRegistration.javalines 114–117). This usesContextFilterMaps.addBefore(), which remembers aninsertPointso that mappings added in front still keep the order in which they were added (StandardContext.javalines 3974–3989). - The chain object is not created fresh for each request.
createFilterChain()reuses the chain attached to Tomcat's internalRequest, and only creates one if there is none (lines 63–68).CoyoteAdaptercreates aRequestobject once, attaches it to the connector-side request object, and reuses it (CoyoteAdapter.javalines 307–321), so the chain is reused along with it. That's whyStandardWrapperValveemptying the array withrelease()in itsfinallyblock matters.
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
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.
- If
pos < n, it takesfilters[pos], movesposup by one (pos++), and calls that filter'sdoFilter(request, response, this). The third argument is the chain itself. - When the filter has done its work and calls
chain.doFilter(), we re-enter the same method.poshas already moved up, so the next filter gets called. - Once all filters are used up (
pos == n), execution falls through to the bottom and callsservlet.service(). This is the spot the comment calls "We fell off the end of the chain". - When
service()finishes, the calls return one by one in the reverse order they were stacked up. The line after a filter'schain.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
- A chain object is only valid within one request. It is a stateful object holding
pos, so if a filter stores thechainit received in a field and uses it for a different request, it will start from the wrong position. For the same reason, callingchain.doFilter()twice inside one filter means the second call findsposalready at the end and just calls the servlet once more. (This is read from the flow of the source; I did not run this case as an example.) - If even one filter is not
asyncSupported, that request can't go async. Lines 108–110 setASYNC_SUPPORTED_ATTRtofalseas the chain passes through. Async servlets are covered in Part 12. lastServicedRequestis not used by default.dispatcherWrapsSameObjectdefaults toGlobals.STRICT_SERVLET_COMPLIANCE(StandardContext.javaline 809), so unless strict mode is on, theThreadLocals at lines 123–126 stay empty.
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
- 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.
- The body written to the buffer is discarded, but headers and cookies stay (
resetBuffer()). - The request is wrapped in an
ApplicationHttpRequestwhose path is changed to the target's, and the original path is kept in attributes such asjakarta.servlet.forward.request_uri(lines 239–261). processRequest()sets the dispatcher type toFORWARDand callsinvoke()(lines 332–335).- 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.
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
- forward: neither
front: beforenorfront: afteris in the body. The first was wiped byresetBuffer(); the second was dropped because the response was already finished (committed=true). TheX-Frontheader survived, and the status code andX-Targetare what target set. Target sawrequestURIas/u09/target, and the original address is inforward.request_uri. - include: the body runs
before → body → after. The 202 andX-Targetthat target set were ignored, so the status is 200 and the header is missing. Target still seesrequestURIas/u09/front, and has to find its own path ininclude.request_uri. - filters: for the client request only
onRequestran, for the forward onlyonForward, for the include onlyonInclude.onRequest, which has no dispatcher, did not run for the forward or the include. The URI thatonIncludeprinted is/u09/front— again because include doesn't change the path.
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
- Code after a forward still runs. The example's
front: back from forwardlog line was printed.forward()is just a method call, so when it returns the next line runs; only the output written there is thrown away. That's why people habitually put areturnafter a forward. - Calling
sendRedirect()orsendError()from an included servlet does nothing. No exception either — it's silently ignored (theif (!included)inApplicationHttpResponse). - This is exactly what Spring Part 20's
OncePerRequestFilterguards against. If you also map a filter toFORWARD, one request passes through that filter twice. Tomcat only gives you a way to filter by dispatcher type; "once per request" is something Spring enforces by leaving a marker in a request attribute.
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.
- If there is an exception:
throwable()— looks it up withcontext.findErrorPage(throwable). It searches by the exception's class name, and if nothing is found, walks up the parent classes (ErrorPageSupport.javalines 107–125). For aServletException, it tries once more with the root cause inside (lines 239–258). If there is still nothing, it changes the status to 500 if it is below 400 and moves on tostatus()(lines 274–287). - If only
sendError()was called, with no exception:status()— looks it up by status code, and failing that, by code0(a default error page with no code) (lines 200–204). - If a page is found, the error details are put into request attributes (
setRequestErrorAttributes(), which also sets the dispatcher type toERROR, lines 291–327), andcustom()sends the request to that location with theRequestDispatcher.forward()from section 5 (lines 375–379). If the response is already committed, it includes instead of forwarding and then closes the connection (lines 357–373). - If nothing is found at all,
ErrorReportValve, further up the Host pipeline, writes Tomcat's default error HTML (Part 8).
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
/missing: the servlet kept running aftersendError(), sosendError(404) returnedwas printed.sendError()only marks the response; the error page is handled after the servlet is done and control is back at the Host valve./boom: there is no page forIllegalStateException, but it went to the page for its parent,RuntimeException. The status code is 500.- In both cases, only the
onErrorfilter ran for the error page, because the dispatcher type isERROR. Line 337 is the branch inprocessRequest()that does not overwrite the dispatcher type withFORWARDwhen it isERROR(compare lines 332–335 in step 4 of section 5). - The bottom of the stack is
StandardHostValve.invoke()line 149 →throwable()→custom()→forward(). The error page is rendered after the servlet has finished and the Wrapper and Context valves have all returned. - The exception log (
SEVERE … threw exception) went tolocalhost.<date>.log, notcatalina.out, becauseStandardWrapperValvelogs it with the container's logger (lines 212–213). Per-logger log files are covered in Part 16.
Easy to get wrong
- A filter can catch an exception only before it turns into an error page. A servlet's exception travels back up the chain and pops out of the filter's
chain.doFilter(), so the filter can catch it withtry/catch. If nobody catches it,StandardWrapperValveturns it into an attribute, and from then on it no longer exists as a Java exception. Spring MVC's@ExceptionHandleris handled much further in, inDispatcherServlet'sprocessDispatchResult()(section 5 of Spring Part 21), so most exceptions never reach Tomcat at all. - The error page servlet is also called with GET. If the
ERROR_METHODattribute is present, forward changes the method toGET(ApplicationDispatcher.javalines 248–252). Even for an error in a POST request, the error page servlet only needsdoGet().
What this post leaves out
- Async dispatch (
request.isAsyncDispatching(),AsyncContextImpl.doInternalDispatch()):StandardWrapperValve.javalines 149–151, 161–163,ApplicationDispatcher.dispatch()lines 418–444 — Part 12 - Unavailable servlets (
UnavailableException,available, 503 andRetry-After):StandardWrapperValve.javalines 103–108, 262–272 - Unloading a servlet (
StandardWrapper.unload(), callingdestroy()and waiting for in-flight requests):StandardWrapper.javafrom line 912 swallowOutput(redirecting a servlet'sSystem.outto the log):SystemLogHandler,StandardWrapperValve.javalines 146–159- Removing the request/response wrappers the dispatcher added (
unwrapRequest()/unwrapResponse()):ApplicationDispatcher.javalines 579–663 - The special case that always preloads the JSP servlet (
isJspServlet):StandardWrapper.javalines 277–289 — JSP is covered in Part 13 - One difference from 11.0.x: on the 11.0.x branch,
FilterMap.addURLPattern()URL-decodes the URL patterns from web.xml before storing them (when the Context settingurlPatternsProvidedInDecodedFormis at its default,false; when it istrue,addURLPatternDecoded()is used, which does not decode). On main, that decoding,addURLPatternDecoded()and the setting itself have all been removed. The code in this post that matches request paths is the same on both branches.
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).
- Part 7: CoyoteAdapter and the Mapper — Which Servlet Gets the Request
- Part 8: Pipelines and Valves — The Processing Chain on Every Container
- Part 12: Async Servlets and Non-Blocking I/O — Letting Go of the Thread
- Spring Part 20: The spring-web Foundation — HttpMessageConverter, WebApplicationContext, Filters
- Spring Part 21: DispatcherServlet — How One Request Becomes a Response