namalzi

← All posts

Digging into Tomcat's Source 10: Deploying a Web App — From a WAR to a Running Context

Tomcat · Published

We follow in the source what happens inside Tomcat when you drop a WAR file into the webapps folder. HostConfig checks the folder every 10 seconds and creates a Context, ContextConfig merges web.xml, conf/web.xml and annotations and finds ServletContainerInitializers, and StandardContext.startInternal() wakes up SCIs, listeners, filters and servlets in a fixed order. We also look, from Tomcat's side, at where Spring's SpringServletContainerInitializer gets called.

What this post covers

This is the tenth post in the "Digging into Tomcat's Source" series. In part 9 we followed a request through the filter chain until it reached a servlet's service(). Up to that point we simply assumed the web app was already "up". This time we take a step back and look at how that web app comes up in the first place. Copy shop.war into the webapps folder and, a moment later, requests to /shop start working. What happens in between?

There are four questions this post tries to answer.

Item Details
Target Apache Tomcat
Version 12.0.0-M1 (main)
Commit 17aa1769b0 (2026-10-23)
Main classes HostConfig, ContextConfig, StandardContext, WebappServiceLoader, WebXml, ExpandWar, ContextName

All line numbers in this post refer to this commit. Part 1 shows how to get the source at the same commit and build the Docker image used for the examples.

ContextConfig.java is over 3,000 lines long and StandardContext.java over 6,000. This post only picks the main path a first-time reader can follow. Things I left out are listed by name and location in "What this post leaves out" at the end.

Terms to know first

Host and Context. <Host name="localhost" appBase="webapps"> in server.xml is a StandardHost object, and each web app is a StandardContext object. A Host has Contexts as its children. We looked at the parent–child relationship of the four containers (Engine → Host → Context → Wrapper) in part 2.

Deploying means "looking at a web app that exists as files, creating a StandardContext for it, attaching it to the Host as a child, and starting it". Undeploying is the opposite: stopping the Context and detaching it from the Host. Auto deploy is the feature where Tomcat keeps checking the appBase folder while it is running, deploying new files and undeploying ones that have disappeared.

A WAR (Web application ARchive) is a zip file that bundles a whole web app folder (WEB-INF/classes, WEB-INF/lib, WEB-INF/web.xml, static files). Only the extension is .war.

Lifecycle listeners and events. Tomcat's components go through the states init → start → stop → destroy, and each time the state changes they notify their attached listeners with events such as before_start and configure_start (part 3). The two stars of this post, HostConfig and ContextConfig, are both listeners. They are attached to a Host and a Context respectively and do their work when an event arrives. Despite the "Config" in their names, they are not objects that hold settings; they are "workers that do the configuring".

The deployment descriptor is the web app configuration file defined by the Servlet specification, WEB-INF/web.xml. There is also a piece that lives inside a jar, META-INF/web-fragment.xml, and Tomcat adds two of its own: conf/web.xml (defaults for every web app) and WEB-INF/tomcat-web.xml. In the source, each of these is read into its own WebXml object.

A ServletContainerInitializer (SCI) is a Servlet specification interface with a single method, onStartup(Set<Class<?>> classes, ServletContext ctx). If you write the name of an implementing class in a jar's META-INF/services/jakarta.servlet.ServletContainerInitializer file, the container calls it when it starts the web app. If you put @HandlesTypes(SomeType.class) on the class, the container finds "the classes that implement or extend that type" and passes them as the first argument. It is the entry point for frameworks that register servlets and filters in code instead of web.xml.

1. The big picture: two listeners and one start method

Deployment is split among three classes.

Who Attached to What it does
HostConfig Listener on the Host Scans appBase, creates Contexts and attaches them to the Host. Scans again periodically
ContextConfig Listener on the Context Reads context.xml, the web.xml files, annotations and SCIs to fill in the Context
StandardContext.startInternal() The Context itself Wakes up what was filled in, in a fixed order (SCI → listeners → filters → servlets)

The two attributes you know from server.xml, <Host ... unpackWARs="true" autoDeploy="true">, are the switches for this post. Both default to true in StandardHost (StandardHost.java line 109, line 152). The Host also holds the class name of the listener to attach to its Contexts. configClass defaults to "org.apache.catalina.startup.ContextConfig" (line 115).

How HostConfig deploys. HostConfig.check() is called either through START_EVENT when StandardHost starts, or through PERIODIC_EVENT that the Engine fires every 10 seconds on the background thread Catalina-utility. check only works when autoDeploy is true; if files have changed or been deleted it reloads or undeploys via checkResources, then calls deployApps. deployApps deploys the xml files in conf/Catalina/localhost, the wars in webapps, then the folders in webapps, in that order. deployWAR creates a StandardContext, attaches a ContextConfig listener, adds it to the Host with host.addChild, and context.start is called from inside that. Blue boxes are HostConfig's work, and the yellow box is the periodic thread that triggers it. deployWAR() is called once per WAR.

2. HostConfig — who watches the webapps folder

It reacts to four events

The events HostConfig receives from the Host, and what it does for each, all fit in one switch in lifecycleEvent().

        switch (event.getType()) {
            case Lifecycle.PERIODIC_EVENT -> check();
            case Lifecycle.BEFORE_START_EVENT -> beforeStart();
            case Lifecycle.START_EVENT -> start();
            case Lifecycle.STOP_EVENT -> stop();
        }

java/org/apache/catalina/startup/HostConfig.java lines 283–288

deployApps() — three kinds, in a fixed order

    protected void deployApps() {
        // Migrate legacy Java EE apps from legacyAppBase
        migrateLegacyApps();
        File appBase = host.getAppBaseFile();
        File configBase = host.getConfigBaseFile();
        String[] filteredAppPaths = filterAppPaths(appBase.list());
        // Deploy XML descriptors from configBase
        deployDescriptors(configBase, configBase.list());
        // Deploy WARs
        deployWARs(appBase, filteredAppPaths);
        // Deploy expanded folders
        deployDirectories(appBase, filteredAppPaths);
    }

java/org/apache/catalina/startup/HostConfig.java lines 401–413

  1. deployDescriptors — conf/Catalina/localhost/*.xml. One file is one <Context docBase="/somewhere/myapp">. This is how you deploy a web app that lives outside webapps.
  2. deployWARs — webapps/*.war.
  3. deployDirectories — folders inside webapps. A folder unpacked from a WAR also counts here, but it is skipped if a Context with the same name is already deployed (deploymentExists()).

The migrateLegacyApps() at the top is a feature that takes javax.*-era apps placed in the webapps-javaee folder (legacyAppBase, StandardHost.java line 92), converts them to jakarta.* and moves them into webapps (from line 1138). The 11.0.x branch has the same call.

Every 10 seconds — who fires PERIODIC_EVENT?

PERIODIC_EVENT is fired by the last line of ContainerBase.backgroundProcess() (ContainerBase.java line 956). Before that, it also calls backgroundProcess() on the cluster, the Realm and the valves in the pipeline. The job that calls this method periodically is scheduled by threadStart() on the server's utility executor.

            backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
                    .scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
                            backgroundProcessorDelay, TimeUnit.SECONDS);

java/org/apache/catalina/core/ContainerBase.java lines 1090–1092

backgroundProcessorDelay defaults to -1 (off) (line 142), and only StandardEngine changes it to 10 in its constructor (StandardEngine.java line 62). So only one periodic job exists, on the Engine, and that job (ContainerBackgroundProcessor.processChildren()) walks recursively through the Hosts, Contexts and Wrappers below it whose delay is 0 or less and calls their backgroundProcess() (lines 1164–1194). That is why the Host receives PERIODIC_EVENT every 10 seconds by default. The threads of this executor have names starting with Catalina-utility- (StandardServer.java line 406). The same periodic job also runs the session expiry check of part 11.

The path that sends PERIODIC_EVENT. The server's utility executor (threads named Catalina-utility-N) runs a task every 10 seconds with scheduleWithFixedDelay. That periodic task exists only on the StandardEngine, because backgroundProcessorDelay defaults to -1 and only the Engine sets it to 10 in its constructor. The task, ContainerBackgroundProcessor.processChildren(), handles its own container and then recursively every child whose delay is 0 or less, going through Engine, Host, Context and Wrapper. Each container's backgroundProcess() calls the cluster, the Realm and the valves in its pipeline, and fires PERIODIC_EVENT on its last line. The Host's listener HostConfig receives that event and does auto-deploy in check(). The same periodic task also runs session expiry (Part 11). The two paragraphs above as one line. There is only one periodic task, on the Engine, and it walks the containers below it recursively.

Here is check().

    protected void check() {

        if (host.getAutoDeploy()) {
            // Check for resources modification to trigger redeployment
            DeployedApplication[] apps = deployed.values().toArray(new DeployedApplication[0]);
            for (DeployedApplication app : apps) {
                if (tryAddServiced(app.name)) {
                    try {
                        checkResources(app, false);
                    } finally {
                        removeServiced(app.name);
                    }
                }
            }

            // Check for old versions of applications that can now be undeployed
            if (host.getUndeployOldVersions()) {
                checkUndeploy();
            }

            // Hotdeploy applications
            deployApps();
        }

java/org/apache/catalina/startup/HostConfig.java lines 1693–1715

For every app it has deployed, it remembers a list of watched files (redeployResources, reloadResources) with their modification times. If a time has changed or a file has disappeared, it redeploys or undeploys the app (checkResources(), from line 1369). Then it calls deployApps() again to deploy any files that appeared in the meantime. There is no separate "detect a new WAR" step; it simply rescans from scratch every time and skips names that are already deployed.

tryAddServiced() is a marker that keeps two threads from working on the same app at the same time. The periodic check must not touch an app while, for example, the Manager web app is in the middle of uploading it.

deployWAR() — create a Context and attach it to the Host

deployWAR() (from line 783), which deploys one WAR, first checks whether the WAR contains META-INF/context.xml. If it does, it reads it with the Digester to create the Context object; otherwise it just creates an instance of contextClass (default StandardContext) (lines 817–865). The important part is at the end.

            Class<?> clazz = Class.forName(host.getConfigClass());
            LifecycleListener listener = (LifecycleListener) clazz.getConstructor().newInstance();
            context.addLifecycleListener(listener);

            context.setName(cn.getName());
            context.setPath(cn.getPath());
            context.setWebappVersion(cn.getVersion());
            context.setDocBase(cn.getBaseName() + ".war");
            host.addChild(context);

java/org/apache/catalina/startup/HostConfig.java lines 916–924

  1. It creates one instance of the Host's configClass, i.e. ContextConfig, and attaches it as a listener on the new Context. From this moment on, the Context's lifecycle events go to ContextConfig (section 3).
  2. It sets the name, path and version. cn is a ContextName parsed from the file name (just below).
  3. The docBase (where the web app's content lives) is set to the WAR file itself for now. Unpacking is done later by ContextConfig.
  4. host.addChild(context). This single line starts the Context.

The last point is easy to miss. addChild does not just add the child to a list; if the parent is already running, it calls start() right there.

        try {
            if ((getState().isAvailable() || LifecycleState.STARTING_PREP.equals(getState())) && startChildren) {
                child.start();
            }
        } catch (LifecycleException e) {
            throw new IllegalStateException(sm.getString("containerBase.child.start"), e);
        }

java/org/apache/catalina/core/ContainerBase.java lines 581–587

So StandardContext.startInternal(), which we look at in section 6, runs inside the call stack of HostConfig.deployWAR(). We confirm this with the stack trace in example 3. Meanwhile, the MapperListener from part 7 hears the ADD_CHILD_EVENT sent by addChild and attaches itself as a listener on the new Context. Then, the moment the Context finishes starting (AFTER_START_EVENT), it registers /shop with the Mapper (MapperListener.java lines 141–143, lines 446–459). That is why requests don't reach the app until it has completely started.

The rule that turns a file name into an address — ContextName

The conversion between file names and Context paths lives in one place, ContextName (ContextName.java lines 49–98). It strips the .war/.xml extension, splits off whatever follows ## as the version, turns # into /, and makes ROOT the empty path.

File name Context path (getPath()) Version Context name (getName())
shop.war /shop (none) /shop
ROOT.war "" (root) (none) ""
api#v1.war /api/v1 (none) /api/v1
shop##v2.war /shop v2 /shop##v2

The ## version is used for parallel deployment. If you put both shop##v1 and shop##v2 on the same path /shop, existing sessions stay on the old version and new sessions go to the new one (choosing which version a request goes to is the Mapper's job, and I did not trace it in the source for this post). Once the old version has no sessions left, checkUndeploy() removes it when undeployOldVersions is on (from line 1751). In this post we only look at the naming rule.

Example 1: adding and removing a WAR while Tomcat is running

I checked whether Tomcat really notices on a 10-second cycle. I packed one servlet into shop##v2.war, copied it after a Tomcat with an empty webapps had started, and asked /shop/hello every second until it returned 200. Then I deleted the WAR and asked until it returned 404.

This example was run with Tomcat 12.0.0-M1-dev, built unchanged from the reference commit (17aa1769b0), in a Docker container (Eclipse Temurin JDK 21). The core of ex/u10-autodeploy/run.sh looks like this.

# A WAR is a zip. Pack it with the JDK's jar tool.
(cd $B/app && jar cf $B/shop##v2.war .)
# ... start Tomcat and wait for it to come up ...
cp "$B/shop##v2.war" /tmp/base/webapps/
for i in $(seq 60); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/shop/hello)
  [ "$code" = 200 ] && break
  sleep 1
done
# ... in the same way, delete the WAR and wait until we get 404 ...

[Abridged from the example ex/u10-autodeploy/run.sh]

===== webapps before copy =====
===== first 200 after about 9 s =====
hello from contextPath=/shop realPath=/tmp/base/webapps/shop##v2/
===== webapps after deploy =====
shop##v2
shop##v2.war
MANIFEST.MF
war-tracker
===== catalina log (deploy) =====
24-Oct-2026 13:42:18.748 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/tmp/base/webapps/shop##v2.war]
24-Oct-2026 13:42:20.033 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/tmp/base/webapps/shop##v2.war] has finished in [1,284] ms
===== after removing the WAR: HTTP 404 after about 10 s =====
===== catalina log (undeploy) =====
24-Oct-2026 13:42:30.045 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.undeploy Undeploying context [/shop##v2]

The timestamps, the number of seconds, the deployment time in milliseconds and the number at the end of the thread name change from run to run (other runs took 8, 10 and 12 seconds to deploy, and once Catalina-utility-2 did the deploying). There are four things to read here.

The timeline of example 1. After Tomcat was up, shop##v2.war was copied into the empty webapps; at 13:42:18.748 the Catalina-utility-1 thread started deploying it with HostConfig.deployWAR and finished at 13:42:20.033, in 1,284 ms. webapps now held shop##v2.war and the expanded folder shop##v2 (with META-INF/war-tracker), and about 9 seconds after the copy /shop/hello returned its first 200, with contextPath=/shop and realPath=/tmp/base/webapps/shop##v2/. When the WAR was deleted, at 13:42:30.045 Catalina-utility-1 undeployed /shop##v2 with HostConfig.undeploy and deleted the expanded folder too, and about 10 seconds after the delete the response was 404. The output above, laid out in time order. The times and seconds come from this run and change when you run it again.

Easy to get wrong: one app at a time, and on the main thread at startup

deployWARs() hands one task per WAR to host.getStartStopExecutor() (lines 714 and 755). From the name you might expect several apps to be deployed in parallel, but the Host's startStopThreads defaults to 1 (ContainerBase.java line 244), and with 1 it uses an InlineExecutorService that runs the task right away on the calling thread (lines 689–701). So with the default settings apps are deployed one after another; during the periodic check Catalina-utility-1 deploys them, as in example 1, and at startup the main thread deploys them itself (you can see it in example 3's stack). If you have several large apps and startup is slow, you can raise it with something like <Host startStopThreads="4">.

3. ContextConfig — the listener that fills in the Context

The ContextConfig that HostConfig attached reacts to six of the Context's events.

        switch (event.getType()) {
            case Lifecycle.CONFIGURE_START_EVENT -> configureStart();
            case Lifecycle.BEFORE_START_EVENT -> beforeStart();
            case Lifecycle.AFTER_START_EVENT -> {
                // Restore docBase for management tools
                if (originalDocBase != null) {
                    context.setDocBase(originalDocBase);
                }
            }
            case Lifecycle.CONFIGURE_STOP_EVENT -> configureStop();
            case Lifecycle.AFTER_INIT_EVENT -> init();
            case Lifecycle.AFTER_DESTROY_EVENT -> destroy();
        }

java/org/apache/catalina/startup/ContextConfig.java lines 300–312

In the order they are called while a Context starts:

  1. AFTER_INIT_EVENT → init() (line 997): reads the context.xml files. conf/context.xml (shared by all apps), conf/Catalina/localhost/context.xml.default (shared by the apps on that Host), and then this app's own configuration file (context.getConfigFile() — the app's META-INF/context.xml or conf/Catalina/localhost/<app name>.xml) are read one after another onto the same Context (contextConfig(), from line 608). The default conf/context.xml contains lines like <WatchedResource>WEB-INF/web.xml</WatchedResource>, so when that file changes the app is reloaded.
  2. BEFORE_START_EVENT → beforeStart() (line 1019): unpacks the WAR with fixDocBase() (just below).
  3. CONFIGURE_START_EVENT → configureStart() (line 1034): the heart of this post. It merges the web.xml files, reads annotations and finds SCIs. When it is done, it calls context.setConfigured(true).

Even when you write a <Context> directly inside server.xml, a Digester rule attaches the same ContextConfig (ContextRuleSet.java line 93). Wherever a Context comes from, there is one worker that fills it in.

The order of events ContextConfig receives as the Context starts, after HostConfig.deployWAR() attaches it. deployWAR() attaches the listener with context.addLifecycleListener(ContextConfig) and leaves docBase as the WAR file itself (shop##v2.war). On AFTER_INIT_EVENT, init() reads conf/context.xml, conf/Catalina/localhost/context.xml.default and the app's own file (META-INF/context.xml etc.) onto the same Context, in that order. On BEFORE_START_EVENT, beforeStart() calls fixDocBase(): if unpackWARs is on, ExpandWar.expand() unpacks the WAR, docBase becomes the expanded folder, the old value is recorded with setOriginalDocBase(), and war-tracker's modification time is set equal to the WAR's. On CONFIGURE_START_EVENT, configureStart() merges web.xml, reads annotations and finds SCIs, then calls context.setConfigured(true). Items 1–3 above, placed after deployWAR(). The two blue boxes are this section; the green box is the subject of sections 4 and 5.

The WAR is unpacked just before startup

If docBase is a .war file and unpackWARs is on, fixDocBase() unpacks it with ExpandWar.expand().

        if (docBaseAbsolute.toLowerCase(Locale.ENGLISH).endsWith(".war") && !docBaseAbsoluteFile.isDirectory()) {
            URL war = UriUtil.buildJarUrl(docBaseAbsoluteFile);
            if (unpackWARs) {
                docBaseAbsolute = ExpandWar.expand(host, war, pathName);
                docBaseAbsoluteFile = new File(docBaseAbsolute);
                if (context instanceof StandardContext) {
                    ((StandardContext) context).setOriginalDocBase(originalDocBase);
                }
            } else {
                ExpandWar.validate(host, war, pathName);
            }

java/org/apache/catalina/startup/ContextConfig.java lines 883–893

After unpacking, it switches docBase to the unpacked folder (line 940) and records the original value (the WAR path) separately with StandardContext.setOriginalDocBase(). That is why getRealPath("/") pointed at the unpacked folder in example 1. With unpackWARs="false", nothing is unpacked and the app is served straight from the WAR.

The META-INF/war-tracker we saw in example 1 is an empty file that ExpandWar creates after it finishes unpacking. It sets this file's modification time to exactly the WAR's modification time (ExpandWar.java lines 168–175). The next time Tomcat starts and finds the unpacked folder already there, it reuses it if the two times match (or if there is no war-tracker at all); otherwise it deletes the folder and unpacks again (lines 97–112). This is how Tomcat notices a WAR that was replaced while it was shut down.

Easy to get wrong: edit the unpacked folder and the WAR still wins

When webapps/shop.war and webapps/shop/ both exist, deployment is based on the WAR. Even if you edit a JSP in the unpacked folder directly, the moment the WAR's modification time changes the folder is deleted and unpacked again. That is the war-tracker comparison above. If unpackWARs="false" and a folder with the same name exists, HostConfig logs a warning that "the WAR takes priority, so the folder is ignored" (hostConfig.deployWar.hiddenDir) (lines 736–749).

4. Merging web.xml — who wins

configureStart() first calls webConfig() (from line 1288). The source comments number the steps from Step 1 to Step 11, which makes it easy to follow.

The steps of webConfig. The app's WEB-INF/web.xml is read first; steps 1 and 2 find web-fragment.xml files in jars and order them; step 3 finds SCIs through META-INF/services; steps 4 and 5 scan class files for @WebServlet, @WebFilter, @WebListener and @HandlesTypes matches. If metadata-complete is false, step 6 merges fragments with the priority web.xml, then fragment, then annotation; if true it is skipped. Step 7 merges tomcat-web.xml and the conf/web.xml defaults, step 9 configureContext moves Wrappers, mappings and filters onto the Context, and step 11 registers the SCIs and the classes found with the Context. The two yellow boxes are the SCI part (section 5). Step 8 (turning JSPs declared with <jsp-file> into servlets) and step 10 (static files inside jars) are left out of the figure.

Here is the middle part as it is.

        // Step 3. Look for ServletContainerInitializer implementations
        if (ok) {
            processServletContainerInitializers();
        }

        if (!webXml.isMetadataComplete() || !typeInitializerMap.isEmpty()) {
            // Steps 4 & 5.
            processClasses(webXml, orderedFragments);
        }

        if (!webXml.isMetadataComplete()) {
            // Step 6. Merge web-fragment.xml files into the main web.xml
            // file.
            if (ok) {
                ok = webXml.merge(orderedFragments);
            }

            // Step 7a
            // merge tomcat-web.xml
            webXml.merge(tomcatWebXml);

            // Step 7b. Apply global defaults
            // Have to merge defaults before JSP conversion since defaults
            // provide JSP servlet definition.
            webXml.merge(defaults);

            // Step 8. Convert explicitly mentioned jsps to servlets
            if (ok) {
                convertJsps(webXml);
            }

            // Step 9. Apply merged web.xml to Context
            if (ok) {
                configureContext(webXml);
            }
        } else {
            webXml.merge(tomcatWebXml);
            webXml.merge(defaults);

java/org/apache/catalina/startup/ContextConfig.java lines 1343–1380

Priority: the app's web.xml → the app's annotations → fragments → defaults

The ordering is scattered across the source, so here it is gathered in a table.

Source Where it is read Priority
WEB-INF/web.xml getContextWebXmlSource() (parsed first, lines 1324–1328) Highest
Annotations in WEB-INF/classes processClasses() step 4 — added straight into the app's webXml If web.xml has the same name, web.xml wins
web-fragment.xml in jars and annotations on classes in jars steps 1, 2 and 5 → step 6 merge() Anything web.xml defines is skipped
WEB-INF/tomcat-web.xml getTomcatWebXmlFragment() (line 1654) 7a, overridable defaults
conf/web.xml + conf/Catalina/localhost/web.xml.default getDefaultWebXmlFragment() (line 1683) 7b, lowest

The same principle is written as a comment inside WebXml.merge() for step 6.

        // As per 'clarification' from the Servlet EG, servlet definitions and
        // mappings in the main web.xml override those in fragments and those in
        // fragments override those in annotations
        // Skip servlet definitions and mappings from fragments that are
        // defined in web.xml

java/org/apache/tomcat/util/descriptor/web/WebXml.java lines 2281–2285

conf/web.xml and tomcat-web.xml are read with setOverridable(true) (lines 1657 and 1738), so servlets coming from them carry a "step aside if the app defines the same name" mark. Step 7b is why every web app has a default servlet (/) and a jsp servlet (*.jsp), and it is also why the default / is not added when the app maps / itself.

What does @WebServlet give up to web.xml?

Annotations are not read by loading the classes. Steps 4 and 5 parse the bytes of the .class files directly with BCEL (org.apache.tomcat.util.bcel, a class file parser bundled with Tomcat).

    protected void processAnnotationsStream(InputStream is, WebXml fragment, boolean handlesTypesOnly,
            Map<String,JavaClassCacheEntry> javaClassCache) throws ClassFormatException, IOException {

        ClassParser parser = new ClassParser(is);
        JavaClass clazz = parser.parse();
        checkHandlesTypes(clazz, javaClassCache);

        if (handlesTypesOnly) {
            return;
        }

        processClass(fragment, clazz);
    }

java/org/apache/catalina/startup/ContextConfig.java lines 2363–2375

Loading thousands of classes into a class loader would be slow, and their static initializers might run when you don't want them to. processClass() picks out the three annotations @WebServlet, @WebFilter and @WebListener and adds them as entries to the WebXml (lines 2384–2402).

processAnnotationWebServlet() (from line 2622), which handles @WebServlet, first decides the name. Without name, the class name (the fully qualified name, package included) becomes the servlet name. If that name already exists in web.xml, it does not create a new definition but adds to web.xml's one, filling in loadOnStartup or init parameters only where web.xml has none. The URL patterns are decided at the end.

        if (!isWebXMLservletDef && urlPatterns != null) {
            fragment.addServlet(servletDef);
        }
        if (urlPatterns != null) {
            if (!fragment.getServletMappings().containsValue(servletName)) {
                for (String urlPattern : urlPatterns) {
                    fragment.addServletMapping(urlPattern, servletName);
                }
            }
        }

java/org/apache/catalina/startup/ContextConfig.java lines 2702–2711

If web.xml gave that servlet even one mapping, the annotation's URL patterns are ignored entirely. They are not combined.

metadata-complete="true"

<web-app metadata-complete="true"> means "this web.xml is everything; don't look at annotations". In the code above, when this is true the code takes the else branch instead of steps 6–9 and skips merging fragments. But the condition for steps 4 and 5 is !webXml.isMetadataComplete() || !typeInitializerMap.isEmpty(). If there is even one SCI with @HandlesTypes, classes are scanned even when metadata-complete is set. In that case, though, handlesTypesOnly is true, so it only looks for @HandlesTypes matches and does not call processClass() (annotation registration). The comment at the top of the method also says that "JARs are still processed for SCIs" even when metadata-complete is set (lines 1297–1312).

Example 2: when web.xml and @WebServlet describe the same servlet

I made two apps with the same classes and the same web.xml, and switched only one to metadata-complete="true". The servlet Hello carries this annotation.

// An example written for this post (not Tomcat source)
// The annotation says: name "hello", address /anno, init parameter greeting=from-annotation.
// web.xml also declares the same name "hello", so we can see how the two are merged.
@WebServlet(name = "hello", urlPatterns = "/anno",
        initParams = @WebInitParam(name = "greeting", value = "from-annotation"))
public class Hello extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        resp.getWriter().println("servlet=" + getServletName()
                + " greeting=" + getInitParameter("greeting"));
    }
}

[From the example ex/u10-webxml-annotation/Hello.java, imports omitted]

web.xml gives the same name hello the init parameter greeting=from-web-xml and the address /xml. There is also one more class, @WebServlet("/only-anno") public class OnlyAnno, which web.xml never mentions. The /merge app gets <Context logEffectiveWebXml="true"/> through META-INF/context.xml, so the final merged web.xml is printed to the log (lines 1385–1387).

===== /merge (metadata-complete=false) =====
GET /merge/xml           servlet=hello greeting=from-web-xml [HTTP 200]
GET /merge/anno          [HTTP 404]
GET /merge/only-anno     servlet=OnlyAnno [HTTP 200]
===== /complete (metadata-complete=true) =====
GET /complete/xml        servlet=hello greeting=from-web-xml [HTTP 200]
GET /complete/anno       [HTTP 404]
GET /complete/only-anno  [HTTP 404]
===== effective web.xml of /merge: servlet-mapping (url-pattern -> servlet-name) =====
  *.jspx -> jsp
  *.jsp -> jsp
  /only-anno -> OnlyAnno
  /xml -> hello
  / -> default

Easy to get wrong: metadata-complete="true" in the log

The header of /merge's final web.xml printed by logEffectiveWebXml said metadata-complete="true", even though its original web.xml said false (give /complete the same setting in a separate run and it is printed as true too). That is because WebXml.toXml() always writes this value as true unless the version is 2.4 (WebXml.java lines 1422–1426). What is printed is the result with annotations already merged in, so it seems to mean that there is no need to look at annotations again when reading it back. Don't read it as "the original setting was true".

5. ServletContainerInitializer — the door Spring comes in through

Finding them: WebappServiceLoader

Step 3, processServletContainerInitializers(), finds SCIs with WebappServiceLoader.

    protected void processServletContainerInitializers() {

        List<ServletContainerInitializer> detectedScis;
        try {
            WebappServiceLoader<ServletContainerInitializer> loader = new WebappServiceLoader<>(context);
            detectedScis = loader.load(ServletContainerInitializer.class);
        } catch (IOException ioe) {
            log.error(sm.getString("contextConfig.servletContainerInitializerFail", context.getName()), ioe);
            ok = false;
            return;
        }

java/org/apache/catalina/startup/ContextConfig.java lines 1833–1843

It reads the same files as the JDK's java.util.ServiceLoader (META-INF/services/<interface name>), but with extra rules for web apps (WebappServiceLoader.load(), from line 98).

Next, for each SCI it reads @HandlesTypes and records "this type → these SCIs" in typeInitializerMap (lines 1845–1877). The class scan in steps 4 and 5 uses this map to find matches. For each class, checkHandlesTypes() (line 2412) walks up the superclasses and interfaces (populateSCIsForCacheEntry(), lines 2538–2591), and only when one of them is a type listed in @HandlesTypes does it load the class and add it to that SCI's share of initializerClassMap. If @HandlesTypes lists an annotation type, it looks for classes carrying that annotation (lines 2457–2481).

Until an SCI is called. WebappServiceLoader.load reads the META-INF/services/jakarta.servlet.ServletContainerInitializer files in jars, container jars first and web app jars after. It reads @HandlesTypes to build typeInitializerMap, a map from type to SCI, then parses class files with BCEL, walking up parents and interfaces to find matches and building initializerClassMap with a list of classes per SCI. It registers them with context.addServletContainerInitializer, passing null when nothing was found. StandardContext.startInternal calls sci.onStartup(classes, servletContext), and SpringServletContainerInitializer, for example, creates and calls the WebApplicationInitializers. The yellow boxes are the two maps that connect SCIs and classes. The grey box is Spring's side.

Registration and invocation happen in different places

ContextConfig does not call the SCIs. In step 11 it only registers them with the Context.

        if (ok) {
            for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializerClassMap.entrySet()) {
                if (entry.getValue().isEmpty()) {
                    context.addServletContainerInitializer(entry.getKey(), null);
                } else {
                    context.addServletContainerInitializer(entry.getKey(), entry.getValue());
                }
            }
        }

java/org/apache/catalina/startup/ContextConfig.java lines 1407–1415

If no class was found, it passes null, not an empty set. StandardContext keeps these in a LinkedHashMap (StandardContext.java line 229, lines 1329–1331) and later, in startInternal() (section 6), calls onStartup() in registration order.

Where it meets Spring

As we saw in Spring part 20, the spring-web jar contains a META-INF/services/jakarta.servlet.ServletContainerInitializer file whose single line is org.springframework.web.SpringServletContainerInitializer, and that class carries @HandlesTypes(WebApplicationInitializer.class). Spring part 20 noted that "searching for the WebApplicationInitializer implementations and passing them in is the servlet container's job, so it wasn't checked against source there". That search is exactly what this section covers. Seen from Tomcat's side, it goes like this.

  1. WebappServiceLoader reads the service file in WEB-INF/lib/spring-web-*.jar and creates SpringServletContainerInitializer with the web app class loader.
  2. It reads @HandlesTypes and puts WebApplicationInitializer → this SCI on the map.
  3. It scans the app's classes and jars with BCEL and collects the classes that have WebApplicationInitializer in their superclass/interface chain.
  4. StandardContext.startInternal() calls onStartup(those classes, servletContext). From there on it is the path Spring part 20 read in the source: filter the classes, instantiate them and call onStartup(servletContext) on each in turn.

You can also see from Tomcat's side why Spring's onStartup filters out interfaces and abstract classes: Tomcat passes everything that has the type in its inheritance chain, abstract classes included. We confirm this in example 3. On the other hand, if you use embedded Tomcat's tomcat.addContext() as in the Spring part 20 example, a FixContextListener is attached instead of ContextConfig (Tomcat.java line 680). This listener sets configured to true and processes a few annotations, but it looks for neither web.xml nor SCIs (lines 1112–1141). That is why that example added the SCI by hand with addServletContainerInitializer(). We look at embedded Tomcat in part 16.

6. StandardContext.startInternal() — the order things wake up in

Now for the method that actually starts the Context. Keeping only the major steps of this method of over 300 lines (lines 4390–4701) gives the figure below.

The order in which StandardContext starts. At BEFORE_START_EVENT, ContextConfig.beforeStart unpacks the WAR; resources are prepared; WebappLoader starts and creates the web app class loader. At CONFIGURE_START_EVENT, ContextConfig.configureStart merges web.xml and finds annotations and SCIs. Child Wrappers and the pipeline start, mergeParameters sets up context-param, SCI.onStartup is called, listenerStart calls ServletContextListeners, the session Manager starts, filterStart calls Filter.init, loadOnStartup calls servlet init, and then the state becomes STARTING and requests are accepted. Blue boxes are where the web app's code is called (SCIs, listeners, filters, servlets) or where ContextConfig does its work. Grey boxes are covered in other parts.

Step What Location (StandardContext.java)
0 BEFORE_START_EVENT → ContextConfig.beforeStart() — unpack the WAR When LifecycleBase.start() switches to STARTING_PREP
1 Prepare resources (StandardRoot) line 4429
2 Start WebappLoader — the web app class loader (part 4) line 4476
3 CONFIGURE_START_EVENT → ContextConfig.configureStart() line 4526
4 Start child Wrappers and the pipeline lines 4529–4539
5 Fail if not configured lines 4576–4579
6 mergeParameters() — context-param line 4603
7 SCI onStartup() lines 4606–4614
8 listenerStart() — ServletContextListener.contextInitialized() lines 4617–4622
9 Start the session Manager (part 11) line 4635
10 filterStart() — Filter.init() line 4644
11 loadOnStartup() — init() of load-on-startup servlets (part 9) line 4652

It matters that step 2 comes before step 3. When ContextConfig finds a @HandlesTypes match it has to load that class, and for that the web app class loader must already exist. Steps 7–11 look like this.

            // Call ServletContainerInitializers
            for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializers.entrySet()) {
                try {
                    entry.getKey().onStartup(entry.getValue(), getServletContext());
                } catch (ServletException e) {
                    log.error(sm.getString("standardContext.sciFail"), e);
                    ok = false;
                    break;
                }
            }

            // Configure and call application event listeners
            if (ok) {
                if (!listenerStart()) {
                    log.error(sm.getString("standardContext.listenerFail"));
                    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 4605–4656

There is a reason SCIs come first. An SCI's onStartup() is the place where new servlets, filters and listeners can be registered with ctx.addServlet(), ctx.addFilter() and ctx.addListener(). Only if listeners are called and filters and servlets initialized after all registration is done do the things an SCI registered get the same treatment as everything else. In the Spring part 20 example, the ContextLoaderListener registered in onStartup was called in the very next step (step 8) and refreshed the root context — thanks to this order.

Example 3: a hand-written SCI and the startup order

I packed MySci into a jar and put it in WEB-INF/lib/my-sci.jar. The jar's META-INF/services/jakarta.servlet.ServletContainerInitializer file contains the single line MySci.

// An example written for this post (not Tomcat source)
// An SCI that goes into a jar in WEB-INF/lib. The META-INF/services file tells Tomcat about it.
// Tomcat finds the classes implementing Plugin (named in @HandlesTypes) and passes them as the first argument.
@HandlesTypes(Plugin.class)
public class MySci implements ServletContainerInitializer {
    @Override
    public void onStartup(Set<Class<?>> classes, ServletContext ctx) {
        Set<String> names = new TreeSet<>();
        if (classes != null) {
            classes.forEach(c -> names.add(c.getName()));
        }
        System.out.println("1. SCI onStartup, @HandlesTypes classes = " + names);

        // Who called me? Print only the org.apache.catalina frames
        for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
            if (e.getClassName().startsWith("org.apache.catalina")) {
                System.out.println("     at " + e.getClassName() + "." + e.getMethodName());
            }
        }

        // Register a servlet in code instead of web.xml (the same API Spring's WebApplicationInitializer uses)
        var reg = ctx.addServlet("fromSci", new HelloServlet());
        reg.addMapping("/hello");
        reg.setLoadOnStartup(1);
    }
}

[From the example ex/u10-sci/sci/MySci.java, imports omitted]

Plugin is an empty interface (inside the jar). In the web app's WEB-INF/classes I put PluginA implements Plugin, abstract class AbstractPlugin implements Plugin, PluginB extends AbstractPlugin, and an unrelated NotAPlugin. There is also one @WebListener listener and one @WebFilter("/*") filter, printing "2." and "3." respectively, and the servlet MySci registers prints "4." in its init(). Before all that, run.sh also prints the SCI service files found in Tomcat's lib/*.jar.

===== SCIs that ship with Tomcat itself (lib/*.jar) =====
  jasper.jar: org.apache.jasper.servlet.JasperInitializer
  tomcat-websocket.jar: org.apache.tomcat.websocket.server.WsSci
hello from a servlet registered by an SCI
===== catalina.out (System.out of the web app) =====
1. SCI onStartup, @HandlesTypes classes = [AbstractPlugin, PluginA, PluginB]
     at org.apache.catalina.core.StandardContext.startInternal
     at org.apache.catalina.util.LifecycleBase.start
     at org.apache.catalina.core.ContainerBase.addChild
     at org.apache.catalina.core.StandardHost.addChild
     at org.apache.catalina.startup.HostConfig.deployDirectory
     at org.apache.catalina.startup.HostConfig$DeployDirectory.run
     at org.apache.catalina.startup.HostConfig.deployDirectories
     at org.apache.catalina.startup.HostConfig.deployApps
     at org.apache.catalina.startup.HostConfig.start
     at org.apache.catalina.startup.HostConfig.lifecycleEvent
     at org.apache.catalina.util.LifecycleBase.fireLifecycleEvent
     at org.apache.catalina.util.LifecycleBase.setStateInternal
     at org.apache.catalina.util.LifecycleBase.setState
     at org.apache.catalina.core.ContainerBase.startInternal
     at org.apache.catalina.core.StandardHost.startInternal
     at org.apache.catalina.util.LifecycleBase.start
     at org.apache.catalina.core.ContainerBase$StartChild.call
     at org.apache.catalina.core.ContainerBase$StartChild.call
     at org.apache.catalina.core.ContainerBase.startInternal
     at org.apache.catalina.core.StandardEngine.startInternal
     at org.apache.catalina.util.LifecycleBase.start
     at org.apache.catalina.core.StandardService.startInternal
     at org.apache.catalina.util.LifecycleBase.start
     at org.apache.catalina.core.StandardServer.startInternal
     at org.apache.catalina.util.LifecycleBase.start
     at org.apache.catalina.startup.Catalina.start
     at org.apache.catalina.startup.Bootstrap.start
     at org.apache.catalina.startup.Bootstrap.main
2. ServletContextListener.contextInitialized
3. Filter.init: LogFilter
4. servlet init (load-on-startup): fromSci

Easy to get wrong: the Host keeps going even when a start fails

If an SCI throws ServletException or a listener fails, ok becomes false and at the end the Context is put into the FAILED state (lines 4690–4700). LifecycleBase.start() treats this as "the component reported its own failure" and just cleans up with stop() without throwing (LifecycleBase.java lines 170–173). So this app does not receive requests, but HostConfig carries on deploying the next app. The log gets a line like Context [/shop##v2] startup failed due to previous errors (standardContext.startFailed), but the real cause is in the error printed above it. If an app only ever returns 404, read the log upwards.

What this post leaves out

Summary

Concept One-line summary
HostConfig Listener on the Host. At startup and on every PERIODIC_EVENT it deploys xml → WARs → folders
Auto-deploy cycle Only the Engine has backgroundProcessorDelay=10. A Catalina-utility thread visits the children every 10 seconds
deployWAR() Creates a Context, attaches ContextConfig, calls host.addChild() — and start() runs inside it
ContextName # → /, after ## is the version, ROOT is the root
Unpacking the WAR fixDocBase() at BEFORE_START → ExpandWar. The war-tracker time reveals a changed WAR
ContextConfig Listener on the Context. context.xml → unpack the WAR → webConfig()
web.xml priority the app's web.xml > annotations in WEB-INF/classes > jar fragments (and that jar's annotations) > tomcat-web.xml > conf/web.xml
Annotation scanning Reads class files with BCEL. If web.xml gives a mapping, @WebServlet's address is dropped
metadata-complete Turns off annotation registration, but @HandlesTypes matching still happens
Finding SCIs WebappServiceLoader — container jars first, web app jars after
@HandlesTypes Matches through the superclass/interface chain. Abstract classes are passed too; null if nothing found
startInternal() order class loader → configureStart → SCI → listeners → filters → load-on-startup servlets

What's next

Now the web app is up and taking requests. When a request arrives, the first thing many apps reach for is the session. How a single request.getSession() creates a JSESSIONID, and how the session Manager started as step 9 of section 6's table removes expired sessions in the same 10-second periodic job, continues in part 11: Sessions — How JSESSIONID Is Made and Removed.

Seen from Spring's side, this post is the scene just before Spring part 20. Spring part 20 started at SpringServletContainerInitializer.onStartup(); this post covered how Tomcat finds that SCI in a jar and gathers the WebApplicationInitializer implementations to pass in. How the DispatcherServlet registered that way receives and handles requests is in Spring part 21. The way app settings are merged on top of the conf/web.xml defaults resembles httpd part 7, where per-directory settings were merged on top of the server-wide configuration.

More posts