Servlet Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Jakarta Servlet 6.1 (Jakarta EE 11), the specification that Apache Tomcat 11.0.26 implements together with Jakarta Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Standard Tag Library 3.0, on Java 21 (the specifications need Java 17 or later). Everything was verified on 2026-10-08: the facts against the Servlet 6.1 specification, its Javadoc and the Tomcat 11.0 documentation, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs shown are the outputs that run produced. Servlets are the layer underneath both halves of this section. A JSP is translated into a servlet (JSP Lifecycle and Translation), and Struts 7 is a filter plus a set of servlet-API calls, so the vocabulary on this page (request, response, scope, filter, dispatcher, mapping, error page) is the vocabulary the rest of the section uses. The running scenario is the Bookshelf, a small catalogue of books that grows from a servlet that lists three titles into an application with sessions, cookies, filters, listeners, error pages and a cover-image upload.

What a Servlet Is and Where It Runs

A servlet is a Java class that a servlet container (Tomcat, Jetty, GlassFish, WildFly and others) instantiates, calls and destroys, in order to turn a request into a response. The specification defines the contract between container and application: the Servlet interface, the request and response objects, the application-wide ServletContext, the packaging of a web application as a WAR, and the deployment descriptor web.xml. Almost every Java web technology sits on top of that contract. A JSP page is compiled into an HttpJspPage servlet, Struts 2 installs a servlet filter, Spring MVC is a single DispatcherServlet, and the Jakarta Faces runtime is the FacesServlet.

The container does the work that is the same for every application: it listens on a socket, parses HTTP, selects the web application and the servlet from the URL, manages threads, tracks sessions, applies filters, handles errors and cleans up. The application supplies only the code that is different.

Table 1. Servlet versions, Jakarta EE releases and the Tomcat lines that implement them (Tomcat "Which Version" page, 2026-10-08)
Servlet Jakarta EE Tomcat line Notes

6.1

EE 11

11.0.x (Java 17 or later)

Current. Pages 4.0, EL 6.0. Redirect control, HttpSession.Accessor, HTTP/2 server push deprecated, jakarta.servlet.error.method, Charset overloads of setCharacterEncoding

6.0

EE 10

10.1.x (Java 11 or later)

Removed the long-deprecated API (SingleThreadModel, HttpSessionContext, HttpUtils), generic cookie attributes (SameSite), RFC 6265, HttpServletRequest.getRequestId

5.0

EE 9

10.0.x (end of life 2022-10-31)

Only the namespace change javax.servlet to jakarta.servlet

4.0

EE 8

9.0.x (Java 8 or later; end of support not before 2027-03-31)

Last javax.servlet line; HTTP/2 push, HttpServletMapping

3.1

Java EE 7

8.5.x (end of life 2024-03-31)

Non-blocking I/O, protocol upgrade; this is what many 2015-era tutorials target

A web application written for the servlet API needs only two Maven dependencies at compile time, both with scope provided because the container supplies them. The Bookshelf of this page uses the same pom.xml shape as the Getting Started page of this section.

The compile-time dependency of every example on this page
<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.1.0</version>
    <scope>provided</scope>
</dependency>

The Bookshelf Used on This Page

All the examples below are classes of one WAR, deployed under the context path /g2-servlet on Tomcat 11.0.26 with the WAR file named g2-servlet.war. The classes live in the package org.example.bookshelf; imports and the package line are left out of the listings. The table lists what each class demonstrates and where the page explains it.

Table 2. The classes of the example WAR
Class Mapping Concept

LifecycleServlet

/lifecycle

init, service, destroy, one instance and many threads

SearchServlet, LegacyEncodingServlet

/search, /search-legacy

parameters, headers, locale, character encoding

BufferServlet

/buffer

response buffer, commit, late header changes

RedirectServlet, BookServlet

/redirects, /books/*

redirect status codes, Post/Redirect/Get, CSRF token, path info

ScopesServlet, CartServlet, PrefsServlet

/scopes, /cart, /prefs

scopes, ServletContext, sessions, cookies

TimingFilter, TrimFilter, FooterFilter, DispatchTraceFilter, AdminFilter

various

chain order, request and response wrappers, dispatcher types, blocking filters

AppListener, SessionTracker, Cart

(none)

context, session, attribute and binding events, programmatic registration

DispatchServlet, FragmentServlet, BoomServlet, ErrorServlet

/dispatch, /fragment/*, /boom, /errors/*

forward, include, named dispatcher, error dispatch

MappingServlet

/map/exact, /map/*, *.report

mapping rules, HttpServletMapping

UploadServlet, AsyncServlet, LogServlet, HomeServlet

/upload, /async, /log, ""

multipart, async, trace, context-root mapping

The HTTP Contract Behind a Servlet

Servlets implement a thin layer over HTTP, so a few protocol facts decide how every servlet is written. HTTP is a request/response protocol: the client sends a request line, headers and optionally a body, and the server answers with a status line, headers and a body. It is stateless: nothing in the protocol links two requests, which is why the servlet API adds sessions on top. The raw exchange for a form POST to the example’s /search servlet is:

curl -sv -d 'title=Dune&tag=sf' http://localhost:18080/g2-servlet/search -o /dev/null
> POST /g2-servlet/search HTTP/1.1
> Host: localhost:18080
> Accept: */*
> Content-Length: 17
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 302

Every line maps to an accessor of HttpServletRequest or a setter of HttpServletResponse: the method and path are getMethod() and getRequestURI(), Host is getServerName(), Content-Type is getContentType(), and the body is the source of getParameter. The same facts, in table form:

Table 3. GET versus POST as a servlet author sees them
GET POST

Meaning

Retrieve a representation; safe and idempotent

Submit data for processing; changes state

Data

In the query string, visible in logs, history and Referer

In the body, application/x-www-form-urlencoded or multipart/form-data

Servlet method

doGet

doPost

Caching and bookmarks

Cacheable, bookmarkable, repeatable by crawlers and prefetchers

Not cached; a browser asks before re-sending

Never use it for

Anything with a side effect, or secrets in the URL

Idempotent lookups that people want to bookmark

Answer after success

200 with the representation

303 redirect to a GET (Post/Redirect/Get)

Servlets are hosted by a servlet container: a server such as Tomcat that implements the Servlet contract and adds HTTP connectors, class loading, session management and deployment. Applets, which ran in the browser, played the other role in 1990s pages; servlets run only on the server and send the browser plain HTML or data.

The Servlet Lifecycle and the Threading Model

The container manages every servlet through a fixed lifecycle expressed by three methods of the Servlet interface. HttpServlet and GenericServlet implement it, so an application normally overrides init() (the no-argument convenience), a doXxx method and destroy().

Table 4. The servlet lifecycle (specification chapter 2)
Phase Container call What the application should know

Load and instantiate

the class is loaded and a public no-argument constructor runs

At startup or lazily on the first request. loadOnStartup (or <load-on-startup>) asks for startup; a non-negative value means "at deployment, lowest numbers first"

Initialize

init(ServletConfig) exactly once

Read init parameters, open expensive resources. If it throws ServletException or UnavailableException, the servlet is not put in service, destroy() is not called, and the container may try a fresh instance later

Serve

service(request, response) for every request, possibly on many threads at once

HttpServlet.service dispatches to doGet, doPost, and the other doXxx methods; a servlet may legitimately handle no request at all

Destroy

destroy() once, after all requests have left service

Release resources. It runs at undeploy or shutdown, or when an UnavailableException marks the servlet permanently unavailable

The rule that shapes every design is in section 2.2 of the specification: unless the application is marked distributable, the container uses one instance per servlet declaration. Every request that reaches that declaration runs on a different thread but calls the same object. Fields of a servlet are therefore shared mutable state, exactly as if they were static.

A servlet with one shared counter, read-only configuration and a destroy hook
@WebServlet(urlPatterns = "/lifecycle", loadOnStartup = 1,
        initParams = @WebInitParam(name = "greeting", value = "Hello from init"))
public class LifecycleServlet extends HttpServlet {

    private final AtomicInteger hits = new AtomicInteger(); // shared by every request thread
    private String greeting;                                 // written once in init, read-only afterwards

    @Override
    public void init() throws ServletException {
        greeting = getInitParameter("greeting");
        EventLog.of(getServletContext()).add("LifecycleServlet.init, instance " + System.identityHashCode(this));
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().printf("%s%ninstance=%d hits=%d thread=%s%n", greeting,
                System.identityHashCode(this), hits.incrementAndGet(), Thread.currentThread().getName());
    }

    @Override
    public void destroy() {
        getServletContext().log("LifecycleServlet.destroy after " + hits.get() + " hits");
    }
}

The shared counter is an AtomicInteger, and greeting is written once in init before any request thread can see the servlet, so both are safe. Requesting the servlet three times, and then sixty more requests that the container served on twenty parallel client connections, shows one instance and a count that never loses an update:

curl -s http://localhost:18080/g2-servlet/lifecycle
seq 1 60 | xargs -P 20 -I{} curl -s -o /dev/null http://localhost:18080/g2-servlet/lifecycle
curl -s http://localhost:18080/g2-servlet/lifecycle | tail -1
Hello from init
instance=1266317426 hits=2 thread=http-nio-18080-exec-18
instance=1266317426 hits=64 thread=http-nio-18080-exec-17

The instance number is identical in every response, while the thread names (http-nio-18080-exec-N) are different threads of Tomcat’s pool. The servlet was marked loadOnStartup = 1, so init ran at deployment, before the first request: the trace servlet of the example (/log) shows it, and redeploying the WAR makes Tomcat call destroy, which the example writes to Tomcat’s application log with ServletContext.log:

contextInitialized: 3 books, title=Bookshelf
LifecycleServlet.init, instance 1266317426
...
INFO [Catalina-utility-2] org.apache.catalina.core.ApplicationContext.log LifecycleServlet.destroy after 64 hits

The specification states the thread-safety rules in several places. They are collected here because they are the source of most production bugs.

Table 5. What is shared between threads, and what is not
Object Thread-safe? Rule

Servlet and filter instances

shared by all requests

Keep fields immutable or thread-safe; never store per-request data in fields. The specification advises against synchronizing service because of the performance cost

HttpServletRequest, HttpServletResponse, PrintWriter, ServletOutputStream

no

Objects vended from the request or response must be assumed non-thread-safe (section 7.7.1); hand them to another thread only through startAsync

HttpSession attribute collection

the container protects its own structure

The objects you store are your responsibility: two requests of one user can run at once (two browser tabs, an AJAX call)

ServletContext attributes

the container protects the map

Stored objects must be thread-safe, as in the example AtomicInteger, CopyOnWriteArrayList and synchronized EventLog

Listener instances

one instance per declaration

Application-scoped listeners need the same care as servlets

SingleThreadModel, the old escape hatch that made a container serialize calls to a servlet, was deprecated for years and removed in Servlet 6.0. A legacy servlet that implements it no longer compiles against the 6.x API; the fix is to remove the instance state, not to add synchronized to every method.

An UnavailableException thrown from service tells the container that the servlet cannot serve, permanently or for a number of seconds. The container then answers 404 (permanent) or 503 with a Retry-After header (temporary) without calling the servlet, and it is allowed to treat both kinds as permanent.

HttpServlet, HTTP Methods and Method Handling

HttpServlet.service reads the HTTP method and calls the matching method. A servlet overrides only the methods it supports, and the base class answers the rest.

Table 6. The doXxx methods of HttpServlet
Method Behaviour

doGet

Safe, idempotent retrieval. Override getLastModified to enable conditional GET (If-Modified-Since) handling

doPost

Form submissions and state changes; the usual target of a Post/Redirect/Get flow

doPut, doDelete, doPatch

Servlet 6.0 added PATCH support to the specification; the container routes them to the corresponding method if you override it

doHead

By default calls doGet and lets the container drop the body, so the headers always match. The legacy behaviour that wrapped the response is deprecated and only enabled by the jakarta.servlet.http.legacyDoHead init parameter

doOptions

Answers with an Allow header built from the methods you overrode

doTrace

Echoes the request, excluding the headers isSensitiveHeader lists (Authorization, Cookie, X-Forwarded, Forwarded, Proxy-Authorization)

CONNECT

A proxy method. By default the container rejects it with 501 and never passes it to a filter or servlet

A method the servlet does not override is answered by the base class, as the example shows with LifecycleServlet, which has only doGet:

curl -sI  http://localhost:18080/g2-servlet/lifecycle | head -2
curl -si -X POST    http://localhost:18080/g2-servlet/lifecycle | head -1
curl -si -X OPTIONS http://localhost:18080/g2-servlet/lifecycle | grep -i '^allow'
curl -si -X TRACE   http://localhost:18080/g2-servlet/lifecycle | head -1
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
HTTP/1.1 405
Allow: GET, HEAD, OPTIONS
HTTP/1.1 405

HEAD works without any code, POST gets 405, and OPTIONS reports exactly the methods the class implements. TRACE never reaches doTrace, because Tomcat’s HTTP connector rejects it with 405 unless the connector attribute allowTrace is set.

Requests: Parameters, Headers, Paths and Attributes

An HttpServletRequest gives the servlet read access to everything the client sent and a small writable map of attributes. The accessors fall into five groups.

Table 7. The main request accessors
Group Methods and rules

Request line

getMethod(), getRequestURI() (the raw path, still percent-encoded, without the query), getRequestURL() (scheme, host, port and path), getQueryString(), getProtocol(), getScheme(), isSecure()

Path elements

getContextPath(), getServletPath(), getPathInfo(). The request URI is always context path plus servlet path plus path info (plus path parameters such as ;jsessionid), see the mapping section

Parameters

getParameter(name), getParameterValues(name), getParameterMap(), getParameterNames(). They merge the query string and, for a form POST with application/x-www-form-urlencoded, the body. Multipart text fields are also parameters when the servlet has @MultipartConfig

Headers

getHeader(name) (first value, name is case-insensitive), getHeaders(name) (all values), getHeaderNames(), getIntHeader, getDateHeader; getLocale() and getLocales() parse Accept-Language; getContentType(), getContentLength(), getCharacterEncoding()

Body and connection

getReader() or getInputStream() (one of them, never both), getRemoteAddr(), getServerName(), getServerPort(), and the 6.0 identifiers getRequestId(), getProtocolRequestId() and getServletConnection()

Attributes

setAttribute, getAttribute, removeAttribute: per-request storage used to pass the model from a controller servlet to a view

The one rule about parameters that surprises people is ordering. Parameters are parsed on the first call to any parameter method, and a form body can be consumed only once. If a servlet or filter reads the body with getReader() or getInputStream() first, the container has nothing left to parse and getParameter returns null for form fields. In Servlet 6.1 a failure during parsing (a malformed body, an exceeded limit) makes the parameter methods throw a runtime exception, so a filter that touches parameters must be prepared to catch it (specification change log, issue 431).

The next servlet prints what the container exposes for a request. It is mapped to /search and, as the filter section will show, runs behind a request wrapper that trims whitespace around every parameter value.

Reading parameters, headers and locale
@WebServlet("/search")
public class SearchServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        handle(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        handle(req, resp);
    }

    private void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");           // the charset comes from web.xml (UTF-8)
        PrintWriter out = resp.getWriter();
        out.println("method=" + req.getMethod() + " query=" + req.getQueryString());
        out.println("request encoding=" + req.getCharacterEncoding() + " type=" + req.getContentType());
        out.println("title=" + req.getParameter("title"));
        out.println("tag=" + Arrays.toString(req.getParameterValues("tag")));
        Map<String, String[]> sorted = new TreeMap<>(req.getParameterMap());
        sorted.forEach((k, v) -> out.println("  param " + k + " -> " + Arrays.toString(v)));
        out.println("Accept-Language=" + req.getHeader("Accept-Language"));
        out.println("locale=" + req.getLocale() + " locales=" + Collections.list(req.getLocales()));
        out.println("Accept (all values)=" + Collections.list(req.getHeaders("Accept")));
        out.println("User-Agent present=" + (req.getHeader("User-Agent") != null));
        out.println("response encoding=" + resp.getCharacterEncoding() + " locale=" + resp.getLocale()
                + " (" + Locale.getDefault() + " default)");
    }
}
curl -s -H 'Accept-Language: es-ES,es;q=0.9,en;q=0.5' \
  'http://localhost:18080/g2-servlet/search?title=%20Dune%20&tag=sf&tag=classic'
method=GET query=title=%20Dune%20&tag=sf&tag=classic
request encoding=UTF-8 type=null
title=Dune
tag=[sf, classic]
  param tag -> [sf, classic]
  param title -> [Dune]
Accept-Language=es-ES,es;q=0.9,en;q=0.5
locale=es_ES locales=[es_ES, es, en]
Accept (all values)=[*/*]
User-Agent present=true
response encoding=UTF-8 locale=en_US (en_US default)

getQueryString() returns the raw text, getParameter("title") returns the decoded and trimmed value Dune, getParameterValues("tag") keeps both values in order, and getLocales() returns the client’s languages in preference order. The request encoding line is UTF-8 and the type is null because the deployment descriptor of the example sets <request-character-encoding>, as the next section explains.

Reading Headers: Content Negotiation in a Controller

Headers drive content negotiation. The BookServlet of the example serves the same catalogue as HTML or JSON depending on the Accept header, and sets Vary: Accept so that a shared cache keeps the two representations apart.

BookServlet.doGet: one URL, two representations, path info for the id
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Catalogue catalogue = Catalogue.of(getServletContext());
        String info = req.getPathInfo();                      // null for /books, "/2" for /books/2

        if (info == null || info.equals("/")) {
            req.setAttribute("books", catalogue.all());
            req.setAttribute("csrf", csrfToken(req.getSession()));
            if (wantsJson(req)) {
                resp.setContentType("application/json");
                resp.setHeader("Vary", "Accept");
                resp.getWriter().write(toJson(catalogue));
                return;
            }
            resp.setHeader("Vary", "Accept");
            req.getRequestDispatcher("/WEB-INF/views/books.jsp").forward(req, resp);
            return;
        }

        long id;
        try {
            id = Long.parseLong(info.substring(1));
        } catch (NumberFormatException e) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Book ids are numbers");
            return;
        }
        Optional<Book> book = catalogue.find(id);
        if (book.isEmpty()) {
            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        req.setAttribute("book", book.get());
        req.getRequestDispatcher("/WEB-INF/views/book.jsp").forward(req, resp);
    }
curl -si -H 'Accept: application/json' http://localhost:18080/g2-servlet/books | sed -n 1,5p
curl -si http://localhost:18080/g2-servlet/books/99 | sed -n 1,2p
curl -si http://localhost:18080/g2-servlet/books/abc | sed -n 1,2p
HTTP/1.1 200
Set-Cookie: JSESSIONID=B190D8D3...; Path=/g2-servlet; HttpOnly; SameSite=Lax
Vary: Accept
Content-Type: application/json;charset=UTF-8
[{"id":1,"title":"Dune","author":"Frank Herbert","year":1965},...]
HTTP/1.1 404
HTTP/1.1 400

The Set-Cookie line appears because the servlet calls req.getSession() to create the CSRF token; the next sections explain that header. /books/99 is answered by sendError(404) and /books/abc by sendError(400, ...), and the error page mechanism described later renders both.

Character Encoding

Encoding is the most common reason for garbled accented characters in a servlet application, and the specification’s defaults are older than the web.

Table 8. The encoding rules of Servlet 6.1 (sections 3.13 and 5.6)
Situation Rule

Request body, application/x-www-form-urlencoded, no charset in Content-Type

The container must decode with US-ASCII and treat %nn values as ISO-8859-1; getCharacterEncoding() returns null to show the charset is unknown

Any other request body without charset

ISO-8859-1, unless the application or container configured something else

Configure the request encoding

<request-character-encoding> in web.xml, ServletContext.setRequestCharacterEncoding at startup, or request.setCharacterEncoding before any parameter is read or any input consumed; calling it afterwards has no effect

Response with no encoding set

ISO-8859-1 for the writer

Configure the response encoding

<response-character-encoding>, ServletContext.setResponseCharacterEncoding, response.setCharacterEncoding, a charset in setContentType, or setLocale with a locale-encoding mapping. Calls after getWriter() or after commit are ignored

URL path and query string

Decoded as UTF-8 per section 12.1; containers may offer a vendor option (Tomcat’s connector has URIEncoding)

Servlet 6.1 API

setCharacterEncoding(Charset) overloads on ServletRequest and ServletResponse (issue 415) next to the String forms

A request that did not declare a charset is the dangerous one, because curl, many mobile clients and hand-written fetch calls omit it. The example declares both encodings once for the whole application:

web.xml: one declaration, every servlet, filter and JSP
<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>

Posting Ñandú in UTF-8 to /search shows the effect, and /search-legacy, which forces the old default with the Servlet 6.1 Charset overload, shows the "before":

The "before": forcing ISO-8859-1 garbles UTF-8 input
@WebServlet("/search-legacy")
public class LegacyEncodingServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        req.setCharacterEncoding(StandardCharsets.ISO_8859_1);   // Servlet 6.1 overload taking a Charset
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().println("title=" + req.getParameter("title"));
    }
}
curl -s -d 'title=Ñandú' http://localhost:18080/g2-servlet/search | sed -n 3p
curl -s -d 'title=Ñandú' http://localhost:18080/g2-servlet/search-legacy
curl -s --data-binary 'title=Ñandú' -H 'Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1' \
  http://localhost:18080/g2-servlet/search | sed -n 3p
title=Ñandú
title=Ãandú
title=Ãandú

The second line is the classic mojibake: UTF-8 bytes decoded as ISO-8859-1. The third line shows that an explicit charset in the Content-Type header of the request wins over the default of web.xml, so a client that declares the wrong charset is believed. curl -d sends no charset at all; that is what browsers do for plain form posts, which is why the <request-character-encoding> default matters. JSP pages declare their own encodings (pageEncoding, contentType) and Character Encoding covers that side.

Responses: Status, Headers, Body and Buffering

The response is the mirror image of the request. A servlet sets the status, adds headers, and writes the body to a PrintWriter (getWriter(), characters) or a ServletOutputStream (getOutputStream(), bytes). It may use one or the other for a response, never both, or the second call throws IllegalStateException.

Table 9. The main response methods
Group Methods and rules

Status

setStatus(int): a normal status, no error page. sendError(int) and sendError(int, String): an error status, clears the buffer, commits, and lets the error page mechanism take over. Servlet 6.1 adds constants for 308, 421, 422 and 426 (SC_PERMANENT_REDIRECT, SC_MISDIRECTED_REQUEST, SC_UNPROCESSABLE_CONTENT, SC_UPGRADE_REQUIRED)

Headers

setHeader, addHeader, setIntHeader, setDateHeader, containsHeader, getHeader. 6.1 clarifies what null and empty names or values do

Type and encoding

setContentType("text/html;charset=UTF-8"), setCharacterEncoding, setLocale, setContentLength, setContentLengthLong

Cookies and URLs

addCookie, encodeURL, encodeRedirectURL; the old encodeUrl and encodeRedirectUrl spellings were removed in 6.0

Buffer

setBufferSize, getBufferSize, isCommitted, flushBuffer, reset, resetBuffer

Redirect

sendRedirect(String) and the 6.1 overloads, next section

The container may buffer output. The status line and headers are written to the wire when the response is committed: the buffer fills, flushBuffer() is called, the content length is reached, or sendError or sendRedirect is called. Before the commit the servlet may change anything (reset() clears body, status and headers, resetBuffer() clears only the body); after it, the rules are unforgiving. The next servlet exercises each of them.

Committing a response, and what a late change does
@WebServlet("/buffer")
public class BufferServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.setBufferSize(16 * 1024);                       // a hint: the container may give more, never less
        PrintWriter out = resp.getWriter();
        out.println("bufferSize>=16384: " + (resp.getBufferSize() >= 16 * 1024));
        switch (String.valueOf(req.getParameter("mode"))) {
            case "reset" -> {
                out.println("draft that will be discarded");
                resp.resetBuffer();                            // clears the body, keeps status and headers
                out.println("final body, committed=" + resp.isCommitted());
            }
            case "late-header" -> {
                out.println("first line");
                resp.flushBuffer();                            // commits: status line and headers are on the wire
                resp.setHeader("X-Too-Late", "1");             // silently ignored
                resp.setStatus(500);                           // silently ignored
                out.println("committed=" + resp.isCommitted());
            }
            case "late-error" -> {
                out.println("first line");
                resp.flushBuffer();
                try {
                    resp.sendError(500, "too late");
                } catch (IllegalStateException e) {
                    out.println("sendError after commit -> IllegalStateException");
                }
                try {
                    resp.resetBuffer();
                } catch (IllegalStateException e) {
                    out.println("resetBuffer after commit -> IllegalStateException");
                }
            }
            case "late-session" -> {
                out.println("first line");
                resp.flushBuffer();
                try {
                    req.getSession();                              // would need a Set-Cookie header
                    out.println("session created, id from cookie=" + req.isRequestedSessionIdFromCookie());
                } catch (IllegalStateException e) {
                    out.println("getSession after commit -> IllegalStateException");
                }
            }
            default -> out.println("modes: reset, late-header, late-error, late-session");
        }
    }
}
curl -si 'http://localhost:18080/g2-servlet/buffer?mode=reset'
curl -si 'http://localhost:18080/g2-servlet/buffer?mode=late-header'
curl -si 'http://localhost:18080/g2-servlet/buffer?mode=late-error'
curl -si 'http://localhost:18080/g2-servlet/buffer?mode=late-session'
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 28

final body, committed=false
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Transfer-Encoding: chunked

bufferSize>=16384: true
first line
committed=true
bufferSize>=16384: true
first line
sendError after commit -> IllegalStateException
resetBuffer after commit -> IllegalStateException
bufferSize>=16384: true
first line
getSession after commit -> IllegalStateException

The four outputs are the lesson of this section. With reset, the draft text and the earlier bufferSize line are gone and the response is a normal 200 with a Content-Length computed from what remained. With late-header, the setHeader("X-Too-Late", ...) and setStatus(500) calls after flushBuffer() were silently ignored: there is no X-Too-Late header, the status is still 200, and the body switched to chunked transfer because the length was unknown at commit time. With late-error and late-session, the calls that cannot be honoured throw IllegalStateException: sendError, sendRedirect, reset, resetBuffer and the creation of a session (which needs a Set-Cookie header). The specification calls this out for setBufferSize too, which must be called before any content is written.

The practical rules are: do all the work that can fail before writing, set headers and status first, create the session before writing, and call sendError or sendRedirect and then return. Frameworks such as Struts (Architecture & Request Lifecycle) and Spring MVC follow these rules by writing the view last.

Redirects, Including the Servlet 6.1 Controls

sendRedirect writes a Location header, sets a redirect status and commits the response. Before Servlet 6.1 the status was always 302 and the container always generated the body. Servlet 6.1 (issue 74) adds overloads that choose the status and control whether the buffer is cleared.

Table 10. The sendRedirect overloads
Signature Behaviour

sendRedirect(String location)

302, buffer cleared, short hypertext body generated by the container

sendRedirect(String location, boolean clearBuffer)

302, and false keeps the body that the servlet already wrote

sendRedirect(String location, int sc)

The status you choose, for example 303, 307 or 308

sendRedirect(String location, int sc, boolean clearBuffer)

Both

Relative locations are accepted. The specification (issue 275) lets a container send them as given, and Tomcat 11 does so by default unless the system property org.apache.catalina.STRICT_SERVLET_COMPLIANCE is true; it is controlled per context by useRelativeRedirects. A relative location without a leading slash is resolved against the current request URI, one with a leading slash against the container root, which is why the example builds the target from getContextPath().

Choosing the redirect status and keeping the body
@WebServlet("/redirects")
public class RedirectServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String target = req.getContextPath() + "/books";
        switch (String.valueOf(req.getParameter("mode"))) {
            case "default" -> resp.sendRedirect(target);                                     // 302
            case "relative" -> resp.sendRedirect("books");                                   // relative to this request URI
            case "303" -> resp.sendRedirect(target, HttpServletResponse.SC_SEE_OTHER);       // 6.1: choose the status
            case "308" -> resp.sendRedirect(target, HttpServletResponse.SC_PERMANENT_REDIRECT); // 6.1 constant for 308
            case "keep-body" -> {
                resp.setContentType("text/plain;charset=UTF-8");
                resp.getWriter().print("my own redirect body");
                resp.sendRedirect(target, HttpServletResponse.SC_TEMPORARY_REDIRECT, false);  // 6.1: do not clear the buffer
            }
            case "bad" -> resp.sendRedirect("http://[bad host");                             // not a valid URL
            default -> resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "unknown mode");
        }
    }
}
for m in default relative 303 308 keep-body bad; do
  curl -si "http://localhost:18080/g2-servlet/redirects?mode=$m" | sed -n '1,2p;/^Content-Length/p'
done
HTTP/1.1 302
Location: /g2-servlet/books
Content-Length: 0
HTTP/1.1 302
Location: books
Content-Length: 0
HTTP/1.1 303
Location: /g2-servlet/books
Content-Length: 0
HTTP/1.1 308
Location: /g2-servlet/books
Content-Length: 0
HTTP/1.1 307
Location: /g2-servlet/books
Content-Length: 20
HTTP/1.1 302
Location: http://[bad host
Content-Length: 0

The keep-body response (the last one) carries the 20 bytes my own redirect body that the servlet wrote, because the false argument stopped the container from clearing them. The Location values are relative, Tomcat’s default; the relative mode shows that the container passes the string through unchanged. The specification says a location that cannot be converted to a valid URL makes sendRedirect throw IllegalArgumentException; Tomcat 11.0.26, with relative redirects enabled, did not reject the malformed http://[bad host that the bad mode sends (the last response above).

The status code matters for forms. After a POST that changed state, answer with 303 See Other so the browser follows with a GET and a refresh cannot repeat the post (the Post/Redirect/Get pattern). 307 and 308 instruct the client to repeat the same method and body, which is right for moving an API endpoint but wrong for form submissions. BookServlet.doPost uses sendRedirect(..., SC_SEE_OTHER) and the walkthrough at the end of this page shows it.

Scopes and the ServletContext

Data in a servlet application lives in one of four scopes, each with its own lifetime and sharing. The scope names are the same ones JSP and EL use, so the model built by a servlet is what the view reads.

Table 11. The scopes of a web application
Scope Object Lifetime Visible to

Page

PageContext (JSP only)

one execution of one JSP page

that page and its tags (covered by Implicit Objects & Scopes)

Request

HttpServletRequest attributes

one request, including its forwards and includes

every servlet, filter and JSP that handles that request

Session

HttpSession attributes

until timeout, invalidate() or shutdown

every request of one client in this web application

Application

ServletContext attributes

until the web application stops

everything in the web application, all users

The ServletContext is the application’s window on the container. There is one per web application per JVM (section 4.2), reachable from a servlet with getServletContext(), from a filter or listener through its config or event, and from a JSP as the application object.

Table 12. What the ServletContext offers
Facility Methods and rules

Attributes

setAttribute, getAttribute, removeAttribute. Shared by all threads and all users. In a distributable application each JVM has its own copy, so context attributes are not a cluster-wide store

Initialization parameters

getInitParameter for <context-param> entries of web.xml: read-only configuration that is not tied to one servlet

Resources

getResource, getResourceAsStream, getResourcePaths read from the WAR (/WEB-INF/..., static files) with a path that starts with /; getRealPath may return null when the container does not unpack the WAR. The 6.1 Javadoc warns that these methods must treat the path as untrusted and canonicalize it

Information

getContextPath, getServerInfo, getMajorVersion/getMinorVersion (the API the container supports) and getEffectiveMajorVersion/getEffectiveMinorVersion (the version of the deployment descriptor)

MIME types

getMimeType(file) consults the <mime-mapping> entries and the container defaults

Dispatching

getRequestDispatcher(path), getNamedDispatcher(name)

Logging

log(String) and log(String, Throwable) write to the container’s application log

Session defaults

getSessionTimeout/setSessionTimeout (minutes), getSessionCookieConfig, setSessionTrackingModes

Encoding defaults

setRequestCharacterEncoding, setResponseCharacterEncoding (only during initialization)

Programmatic configuration

addServlet, addFilter, addListener and the Registration API, only while the context is being initialized

Temporary directory

the attribute jakarta.servlet.context.tempdir, a private writable directory per web application

The next servlet touches each of the four scopes and prints the facts the context knows:

Request, session and application scope, and a tour of ServletContext
@WebServlet("/scopes")
public class ScopesServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        ServletContext ctx = getServletContext();
        resp.setContentType("text/plain;charset=UTF-8");
        PrintWriter out = resp.getWriter();

        req.setAttribute("who", "request");                          // gone when this request ends
        HttpSession session = req.getSession();                      // creates the session on first use
        AtomicInteger perSession = (AtomicInteger) session.getAttribute("count");
        if (perSession == null) {
            perSession = new AtomicInteger();
            session.setAttribute("count", perSession);               // survives across requests of one client
        }
        int total = ((AtomicInteger) ctx.getAttribute("visits")).incrementAndGet(); // shared by everyone

        out.println("request attribute who=" + req.getAttribute("who"));
        out.println("session count=" + perSession.incrementAndGet() + " (new=" + session.isNew() + ")");
        out.println("application visits=" + total);
        out.println("servlet API " + ctx.getMajorVersion() + "." + ctx.getMinorVersion()
                + ", effective " + ctx.getEffectiveMajorVersion() + "." + ctx.getEffectiveMinorVersion());
        out.println("context path=" + ctx.getContextPath() + " name=" + ctx.getServletContextName());
        out.println("context-param catalogue.title=" + ctx.getInitParameter("catalogue.title"));
        out.println("mime of cover.png=" + ctx.getMimeType("cover.png") + ", of feed.rss=" + ctx.getMimeType("feed.rss"));
        out.println("session timeout (minutes)=" + ctx.getSessionTimeout());
        out.println("request/response encoding=" + ctx.getRequestCharacterEncoding() + "/" + ctx.getResponseCharacterEncoding());
        out.println("resource /WEB-INF/web.xml exists=" + (ctx.getResource("/WEB-INF/web.xml") != null));
        out.println("resource /WEB-INF/../secret exists=" + (ctx.getResourceAsStream("/WEB-INF/../secret") != null));
    }
}
curl -s -c jar -b jar http://localhost:18080/g2-servlet/scopes      # first request of this client
curl -s -c jar -b jar http://localhost:18080/g2-servlet/scopes | sed -n 1,3p
curl -s http://localhost:18080/g2-servlet/scopes | sed -n 1,3p       # another client, no cookie
request attribute who=request
session count=1 (new=true)
application visits=1
servlet API 6.1, effective 6.1
context path=/g2-servlet name=Bookshelf servlet fundamentals
context-param catalogue.title=Bookshelf
mime of cover.png=image/png, of feed.rss=application/rss+xml
session timeout (minutes)=30
request/response encoding=UTF-8/UTF-8
resource /WEB-INF/web.xml exists=true
resource /WEB-INF/../secret exists=false
request attribute who=request
session count=2 (new=false)
application visits=2
request attribute who=request
session count=1 (new=true)
application visits=3

The session count belongs to the cookie jar: the same client sees 2, a client without the cookie starts again at 1. The application count belongs to the context and keeps growing across clients. The feed.rss MIME type comes from the <mime-mapping> in the example’s web.xml, and the traversal attempt in the last line is refused: the path /WEB-INF/../secret yields null. Treat any path that includes user input as hostile and never pass it to getResource without checking it against an allowlist.

Sessions

HTTP is stateless, so the container associates requests of one client through a session id. The HttpSession interface hides the mechanism. Cookies are the mechanism every container must support, URL rewriting is the fallback, and an SSL session id is a third, rarely used option.

Table 13. Session tracking mechanisms (specification chapter 7)
Mechanism How it works Notes

Cookie

The container sends a cookie named JSESSIONID (renamable with <cookie-config><name>) and the client returns it on every request

Required of every container. Configure HttpOnly, Secure, path and extra attributes such as SameSite with <cookie-config> or SessionCookieConfig

URL rewriting

The id is appended to the path as ;jsessionid=... by response.encodeURL and encodeRedirectURL

Exposes the id in logs, bookmarks, Referer headers and the address bar; the specification itself says not to use it where cookies or SSL sessions are available

SSL session

The TLS session identifies the client

Container specific and rarely used

The session lifecycle is: created lazily by request.getSession() (or getSession(true)); looked up without creating by getSession(false), which returns null when there is none; replaced on privilege change with changeSessionId(); ended by invalidate() or by the idle timeout. A session is new until the client has returned the cookie, so the first request is the only one on which isNew() is true, and a servlet must work when the client never joins the session.

Timeouts have two scales: ServletContext.setSessionTimeout and <session-timeout> use minutes, while HttpSession.setMaxInactiveInterval uses seconds, and a zero or negative value means the session never expires.

The Cart class stores the books a visitor picked, and the CartServlet exercises the API:

A session attribute: Serializable, and told when it is bound
public class Cart implements Serializable, HttpSessionBindingListener {
    private static final long serialVersionUID = 1L;

    private final List<Long> bookIds = new ArrayList<>();

    public synchronized void add(long id) {
        bookIds.add(id);
    }

    public synchronized List<Long> ids() {
        return List.copyOf(bookIds);
    }

    @Override
    public void valueBound(HttpSessionBindingEvent event) {
        EventLog.of(event.getSession().getServletContext()).add("Cart bound as " + event.getName());
    }

    @Override
    public void valueUnbound(HttpSessionBindingEvent event) {
        EventLog.of(event.getSession().getServletContext()).add("Cart unbound from " + event.getName());
    }
}

Objects stored in a session follow a few rules that come from the container, not from the language. They should be small and Serializable (the container may persist or replicate the session; Tomcat’s standard manager preserves sessions across restarts when its pathname attribute is set), thread-safe or guarded, because the same session can serve two requests at once, and well-behaved JavaBeans (public no-argument constructor, getXxx/isXxx/setXxx accessors) when a JSP is going to read them through EL. An object that needs to know when it joins or leaves a session implements HttpSessionBindingListener, as Cart does; one that holds non-serializable resources implements HttpSessionActivationListener to drop and re-acquire them around passivation. The bean conventions themselves are covered by Standard Actions & JavaBeans.

Creating, rotating, expiring and invalidating a session
@WebServlet("/cart")
public class CartServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        HttpSession session = req.getSession();
        Cart cart = (Cart) session.getAttribute("cart");
        resp.setContentType("text/plain;charset=UTF-8");
        PrintWriter out = resp.getWriter();
        out.println("new=" + session.isNew() + " idFromCookie=" + req.isRequestedSessionIdFromCookie()
                + " idFromURL=" + req.isRequestedSessionIdFromURL() + " valid=" + req.isRequestedSessionIdValid());
        out.println("maxInactiveInterval(s)=" + session.getMaxInactiveInterval());
        out.println("cart=" + (cart == null ? "empty" : cart.ids()));
        out.println("encodeURL=" + resp.encodeURL(req.getContextPath() + "/cart"));
        out.println("encodeRedirectURL=" + resp.encodeRedirectURL(req.getContextPath() + "/books"));
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String action = String.valueOf(req.getParameter("action"));
        HttpSession session = req.getSession();
        switch (action) {
            case "add" -> {
                Cart cart = (Cart) session.getAttribute("cart");
                if (cart == null) {
                    cart = new Cart();
                    session.setAttribute("cart", cart);
                }
                cart.add(Long.parseLong(req.getParameter("book")));
            }
            case "rotate" -> req.changeSessionId();       // new id, same attributes: do it after any privilege change
            case "timeout" -> session.setMaxInactiveInterval(Integer.parseInt(req.getParameter("seconds")));
            case "logout" -> session.invalidate();         // listeners run, attributes are unbound
            default -> {
                resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "unknown action");
                return;
            }
        }
        resp.sendRedirect(req.getContextPath() + "/cart", HttpServletResponse.SC_SEE_OTHER);
    }
}
J=jar; B=http://localhost:18080/g2-servlet
curl -s -c $J -b $J $B/cart
curl -si -c $J -b $J -d 'action=add&book=1' $B/cart | head -3
curl -s -c $J -b $J -d 'action=add&book=3' -L $B/cart | sed -n 3p
curl -s -c $J -b $J -d 'action=rotate' -L $B/cart | sed -n 1,3p
curl -s -c $J -b $J -d 'action=timeout&seconds=120' -L $B/cart | sed -n 2p
curl -s -c $J -b $J -d 'action=logout' -L $B/cart | head -4
new=false idFromCookie=true idFromURL=false valid=true
maxInactiveInterval(s)=1800
cart=empty
encodeURL=/g2-servlet/cart
encodeRedirectURL=/g2-servlet/books
HTTP/1.1 303
Location: /g2-servlet/cart
Content-Length: 0
new=false idFromCookie=true idFromURL=false valid=true
maxInactiveInterval(s)=1800
cart=[1, 3]
new=false idFromCookie=true idFromURL=false valid=true
maxInactiveInterval(s)=1800
cart=[1, 3]
maxInactiveInterval(s)=120
new=true idFromCookie=true idFromURL=false valid=false
maxInactiveInterval(s)=1800
cart=empty
encodeURL=/g2-servlet/cart

The session id after rotate ends in a different four characters (...0D4E before, ...AD5F after) while the cart keeps its content: changeSessionId() swaps the identifier and keeps the attributes. After logout, the browser still presents the old cookie, so idFromCookie=true and valid=false tell the servlet that the id it was given is dead, and a fresh, empty session replaced it. Every state change is a POST answered by a 303 redirect.

The listeners of the example record what the container did (an excerpt of the /log trace, the listener section shows the code):

sessionCreated, active=4
Cart bound as cart
session attribute added: cart
sessionIdChanged (old id ends ...0D4E)
sessionDestroyed, active=3
session attribute removed: count
Cart unbound from cart
session attribute removed: cart

sessionIdChanged fires when changeSessionId() runs, and invalidate() produces sessionDestroyed and unbinds every attribute, calling valueUnbound on Cart.

Session Security and Configuration

Four settings decide whether session handling is safe, and web.xml expresses all of them:

web.xml: timeout, cookie attributes and tracking mode
<session-config>
  <session-timeout>30</session-timeout>
  <cookie-config>
    <http-only>true</http-only>
    <attribute>
      <attribute-name>SameSite</attribute-name>
      <attribute-value>Lax</attribute-value>
    </attribute>
  </cookie-config>
  <tracking-mode>COOKIE</tracking-mode>
</session-config>
Table 14. Session hardening checklist
Risk Defence in the servlet API

Session fixation: an attacker plants a known id, the victim logs in

Call request.changeSessionId() (or invalidate and create a new session) at the moment of login; LoginServlet of the example does, and the sessionIdChanged event confirms it

Session id in the URL

Restrict tracking to cookies with <tracking-mode>COOKIE</tracking-mode>. Without it, the example showed ;jsessionid=... on the first response, see below

Cookie theft by script

<http-only>true</http-only>. The example sets it, and the Set-Cookie lines above show HttpOnly

Cookie sent on the wire in clear text or cross-site

<secure>true</secure> when the site is served over HTTPS (it is left off in the example, which runs on plain HTTP), plus SameSite

Idle sessions that never die

<session-timeout> in minutes; zero or negative means never expires

Non-serializable attributes in a clustered or persistent store

distributable applications must store only Serializable objects, or the container throws IllegalArgumentException

Using a session outside a request (WebSocket, background threads)

New in 6.1: HttpSession.getAccessor() returns an HttpSession.Accessor whose access(Consumer) runs a callback with the session, so you never keep a stale HttpSession reference

To see URL rewriting, the same WAR was deployed a second time without the <tracking-mode> element, and a client that does not yet hold a cookie asked for /cart:

curl -si http://localhost:18080/g2-servlet-var/cart | grep -i 'set-cookie\|encode'
Set-Cookie: JSESSIONID=F669A335...; Path=/g2-servlet-var; HttpOnly; SameSite=Lax
encodeURL=/g2-servlet-var/cart;jsessionid=F669A335...
encodeRedirectURL=/g2-servlet-var/books;jsessionid=F669A335...

Until the container sees the cookie come back it cannot know that the client accepts cookies, so it makes encodeURL add the id to every link of that first page. The id then appears in the HTML, in any Referer header and in server logs. On the second request, with the cookie, encodeURL returned the plain path. With <tracking-mode>COOKIE</tracking-mode> (the g2-servlet deployment) the rewriting never happens, which is the right choice for any site that does not need to serve cookieless clients.

Cookies

A cookie is a name and value that the server asks the client to store and return. HttpServletResponse.addCookie emits a Set-Cookie header, HttpServletRequest.getCookies() reads the Cookie header and returns null when there is none. A browser sends back only the name and value, never the attributes, so getCookies() objects have no path, age or HttpOnly flag that you could inspect.

Servlet 6.0 moved the Cookie class from RFC 2109 to RFC 6265 and introduced generic attributes (issue 175): any attribute can be set with setAttribute(name, value), which is how SameSite and Partitioned are written, and 6.1 clarified valueless attributes such as HttpOnly, Secure and Partitioned (issue 571). setComment and setVersion are deprecated for removal because RFC 6265 no longer has them.

Table 15. Cookie attributes
Attribute API Meaning

Path

setPath

URL prefix that receives the cookie; default is the request path of the response, so set it explicitly (the context path)

Domain

setDomain

Host scope; leave it unset for a host-only cookie, which is the safest

Max-Age / Expires

setMaxAge(seconds)

Positive: persistent for that long. Negative (default): browser session only. Zero: delete (use the same name and path)

HttpOnly

setHttpOnly(true)

Hidden from JavaScript

Secure

setSecure(true)

Sent only over HTTPS

SameSite

setAttribute("SameSite", "Lax")

Strict, Lax or None (None also requires Secure) controls cross-site sending, a CSRF mitigation, not a replacement for a token

Partitioned

setAttribute("Partitioned", "")

Opts the cookie into partitioned third-party storage; an empty value writes the bare attribute

Setting, reading and clearing a preference cookie
@WebServlet("/prefs")
public class PrefsServlet extends HttpServlet {

    private static final Set<String> THEMES = Set.of("light", "dark");

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        String theme = req.getParameter("theme");
        if (theme != null) {
            if (!THEMES.contains(theme)) {                     // never copy raw input into a cookie value
                resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "unknown theme");
                return;
            }
            Cookie cookie = new Cookie("theme", theme);
            cookie.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath());
            cookie.setMaxAge(30 * 24 * 60 * 60);                // seconds; -1 = browser session, 0 = delete
            cookie.setHttpOnly(true);
            cookie.setSecure(req.isSecure());                   // Secure only makes sense over HTTPS
            cookie.setAttribute("SameSite", "Lax");              // Servlet 6.0: generic cookie attributes
            if ("1".equals(req.getParameter("partitioned"))) {
                cookie.setAttribute("Partitioned", "");          // attribute without a value
            }
            resp.addCookie(cookie);
            resp.getWriter().println("theme cookie set to " + theme);
        } else if ("1".equals(req.getParameter("clear"))) {
            Cookie cookie = new Cookie("theme", "");
            cookie.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath());
            cookie.setMaxAge(0);                               // same name and path, or the browser keeps the old one
            resp.addCookie(cookie);
            resp.getWriter().println("theme cookie cleared");
        } else {
            Cookie[] cookies = req.getCookies();               // null when the request has no Cookie header
            String current = "none";
            if (cookies != null) {
                for (Cookie c : cookies) {
                    if (c.getName().equals("theme")) {
                        current = c.getValue();
                    }
                }
            }
            resp.getWriter().println("theme=" + current + " (browsers send only name and value back)");
        }
    }
}
curl -si 'http://localhost:18080/g2-servlet/prefs?theme=dark' | grep -i set-cookie
curl -si 'http://localhost:18080/g2-servlet/prefs?theme=dark&partitioned=1' | grep -i set-cookie
curl -s  -H 'Cookie: theme=dark; JSESSIONID=x' http://localhost:18080/g2-servlet/prefs
curl -si 'http://localhost:18080/g2-servlet/prefs?theme=%3Cscript%3E' | head -1
curl -si 'http://localhost:18080/g2-servlet/prefs?clear=1' | grep -i set-cookie
Set-Cookie: theme=dark; Expires=Sat, 07 Nov 2026 10:24:32 GMT; Max-Age=2592000; Path=/g2-servlet; HttpOnly; SameSite=Lax
Set-Cookie: theme=dark; Expires=Sat, 07 Nov 2026 10:24:32 GMT; Max-Age=2592000; Path=/g2-servlet; HttpOnly; SameSite=Lax; Partitioned
theme=dark (browsers send only name and value back)
HTTP/1.1 400
Set-Cookie: theme=; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/g2-servlet

The first two lines show the SameSite and Partitioned attributes written exactly as the Javadoc table says. The value is validated against an allowlist before it reaches Set-Cookie, because cookie values built from request input are a header-injection and session-poisoning vector. The last line is how a cookie is deleted: same name and path, expiry in the past. The Secure flag is false in the example only because the scratch Tomcat speaks plain HTTP; on a real site always keep it on, and prefer server-side sessions for anything that identifies a user.

Filters, Wrappers and the Filter Chain

A filter is an object that intercepts requests to a servlet, a JSP or a static file, and responses on the way back. It can inspect or reject the request, wrap the request or response to change what later components see, and post-process the response. Authentication, logging, compression, character-encoding fixes, CORS headers and page layout (SiteMesh) are all filters, and so is the Struts 2 entry point, StrutsPrepareAndExecuteFilter (see Architecture & Request Lifecycle).

Table 16. The Filter contract
Member Rule

init(FilterConfig)

Called once per declaration before the first request; FilterConfig gives init parameters and the ServletContext. Throwing UnavailableException marks the filter unusable

doFilter(request, response, chain)

Called for every matching request, on the same thread as the servlet. Calls chain.doFilter(request, response) to continue, with the originals or with wrappers, or does not call it to block the request and write the response itself

destroy()

Called once when the filter is removed from service

One instance

One instance per <filter> declaration, shared by all threads, like a servlet. Declaring the same class twice creates two instances

Declaring and Ordering Filters

A filter is declared with @WebFilter or a <filter> element, and attached with <filter-mapping> to URL patterns or servlet names. The order of the chain is defined by the specification (section 6.2.4): first all <url-pattern> mappings that match the request, in the order they appear in web.xml, then all <servlet-name> mappings that match, in the order they appear. The last filter calls the servlet. The order of filters declared only by annotations is unspecified, so a chain whose order matters needs web.xml (or programmatic registration, where isMatchAfter controls the position).

The Bookshelf declares three instances of one timing filter, all with <async-supported>, in this order: a mapping by servlet name (written first), then outer and inner for /*:

A pass-through filter: code before and after chain.doFilter
public class TimingFilter implements Filter {
    private ServletContext ctx;
    private String label;

    @Override
    public void init(FilterConfig config) {
        ctx = config.getServletContext();
        label = config.getInitParameter("label");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        boolean trace = req.getHeader("X-Trace") != null;
        long start = System.nanoTime();
        if (trace) {
            EventLog.of(ctx).add(label + " before " + req.getDispatcherType() + " " + req.getRequestURI());
        }
        try {
            chain.doFilter(request, response);               // everything after this line runs on the way back
        } finally {
            if (trace) {
                EventLog.of(ctx).add(label + " after (" + (System.nanoTime() - start) / 1000 + " us)");
            }
        }
    }
}
web.xml: a filter declaration (the others differ only by name and label)
<filter>
  <filter-name>by-servlet-name</filter-name>
  <filter-class>org.example.bookshelf.TimingFilter</filter-class>
  <async-supported>true</async-supported>
  <init-param><param-name>label</param-name><param-value>by-servlet-name</param-value></init-param>
</filter>

The full list of mappings, in declared order, is:

web.xml: the filter-mapping elements
<!-- Declared first, but matched by servlet name, so it runs after every url-pattern match. -->
<filter-mapping>
  <filter-name>by-servlet-name</filter-name>
  <servlet-name>fragment</servlet-name>
  <dispatcher>REQUEST</dispatcher>
  <dispatcher>FORWARD</dispatcher>
</filter-mapping>
<filter-mapping>
  <filter-name>outer</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
  <filter-name>inner</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
  <filter-name>trim</filter-name>
  <url-pattern>/search</url-pattern>
</filter-mapping>
<filter-mapping>
  <filter-name>footer</filter-name>
  <url-pattern>/WEB-INF/views/*</url-pattern>
  <dispatcher>FORWARD</dispatcher>
</filter-mapping>

A request that carries the header X-Trace makes the filters write to the trace, which /log shows:

curl -s -H 'X-Trace: 1' http://localhost:18080/g2-servlet/fragment/x -o /dev/null
curl -s http://localhost:18080/g2-servlet/log
outer before REQUEST /g2-servlet/fragment/x
inner before REQUEST /g2-servlet/fragment/x
DispatchTrace saw REQUEST /g2-servlet/fragment/x
by-servlet-name before REQUEST /g2-servlet/fragment/x
by-servlet-name after (160 us)
inner after (313 us)
outer after (328 us)

The picture matches the figure below: outer and inner (url-pattern mappings, in file order) run before the filter that was declared first but matches by servlet name, and the code after chain.doFilter runs in reverse. DispatchTraceFilter is declared by annotation only, so its position relative to the XML-declared filters is the container’s choice; in Tomcat 11 it ran after the XML url-pattern filters, but the specification does not promise that.

The filter chain for a request and the five dispatcher types

Dispatcher Types: Why a Filter Does Not Run on a Forward

A <filter-mapping> without <dispatcher> elements applies to REQUEST only. A filter that must also see forwards, includes, error pages or asynchronous dispatches names them explicitly; with annotations the attribute is dispatcherTypes.

Table 17. The dispatcher types of a filter mapping (specification 6.2.5)
Value When the filter runs

REQUEST

The request comes straight from the client. This is the default when no <dispatcher> is written

FORWARD

The request is processed under a RequestDispatcher.forward() to the matching resource

INCLUDE

Under a RequestDispatcher.include() to the matching resource

ERROR

The resource is an error page invoked by the error page mechanism

ASYNC

Under an AsyncContext.dispatch call

The annotated DispatchTraceFilter lists all of them for the paths of the next examples:

Observing every dispatcher type
@WebFilter(urlPatterns = {"/dispatch", "/fragment/*", "/errors/*", "/boom"},
        dispatcherTypes = {DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ERROR})
public class DispatchTraceFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        if (req.getHeader("X-Trace") != null) {
            EventLog.of(req.getServletContext()).add("DispatchTrace saw " + req.getDispatcherType() + " "
                    + req.getRequestURI());
        }
        chain.doFilter(request, response);
    }
}

Wrapping the Request

HttpServletRequestWrapper and HttpServletResponseWrapper implement the decorator pattern: a filter creates a subclass that overrides a few methods, passes the wrapper to chain.doFilter, and every servlet, JSP and later filter sees the modified behaviour without being aware of it. The container must pass the wrapper itself (or a wrapper of it) on to the next component (section 6.2.2).

The TrimFilter below wraps the request so that every parameter value reaches the servlet already trimmed. A wrapper must override all the accessors that expose the same data (getParameter, getParameterValues, getParameterMap, and getParameterNames uses the unchanged keys), or code that uses the one you forgot sees the raw value.

A request wrapper that trims parameters
public class TrimFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        chain.doFilter(new TrimmedRequest((HttpServletRequest) request), response);
    }

    static final class TrimmedRequest extends HttpServletRequestWrapper {
        TrimmedRequest(HttpServletRequest request) {
            super(request);
        }

        @Override
        public String getParameter(String name) {
            String value = super.getParameter(name);
            return value == null ? null : value.strip();
        }

        @Override
        public String[] getParameterValues(String name) {
            String[] values = super.getParameterValues(name);
            return values == null ? null : Arrays.stream(values).map(String::strip).toArray(String[]::new);
        }

        @Override
        public Map<String, String[]> getParameterMap() {   // override all accessors, or the filter has holes
            return super.getParameterMap().entrySet().stream().collect(Collectors.toMap(
                    Map.Entry::getKey, e -> Arrays.stream(e.getValue()).map(String::strip).toArray(String[]::new)));
        }
    }
}

It is mapped only to /search and produced the trimmed title=Dune result shown in the request section, for a query string that contained %20Dune%20.

Wrapping the Response and Why FORWARD Matters

A response wrapper captures the output of the rest of the chain so the filter can change it before it reaches the client: compress it, rewrite links, decorate it with a layout. The FooterFilter buffers a page in a CharArrayWriter and appends an HTML comment after the content of every text/html response:

A response wrapper that appends a footer to HTML
public class FooterFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        CapturingResponse wrapper = new CapturingResponse((HttpServletResponse) response);
        chain.doFilter(request, wrapper);
        String type = wrapper.getContentType();
        if (wrapper.captured() != null && type != null && type.startsWith("text/html")) {
            String html = wrapper.captured().toString();
            response.getWriter().write(html);
            response.getWriter().write("<!-- rendered by Bookshelf -->\n");
        } else if (wrapper.captured() != null) {
            response.getWriter().write(wrapper.captured().toString());
        }
    }

    static final class CapturingResponse extends HttpServletResponseWrapper {
        private CharArrayWriter buffer;
        private PrintWriter writer;

        CapturingResponse(HttpServletResponse response) {
            super(response);
        }

        @Override
        public PrintWriter getWriter() {
            if (writer == null) {
                buffer = new CharArrayWriter();
                writer = new PrintWriter(buffer);
            }
            return writer;
        }

        CharArrayWriter captured() {
            if (writer != null) {
                writer.flush();
            }
            return buffer;
        }
    }
}

The page it decorates is the output of a JSP, and the JSP is reached by a forward from BookServlet. The first version of the mapping used the default REQUEST dispatcher on /books/*, and the result was an empty page:

HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 0

The filter had captured the HTML (149 characters in the debug run), but it was written to a response that Tomcat had already finished. The specification requires the container to send, commit and close the response before forward returns, and Tomcat does that to the real response, not to the wrapper, so code that runs after chain.doFilter in a REQUEST filter is too late. The fix is to run the filter around the forwarded resource. The final mapping applies the filter to the JSP views under WEB-INF and only for the FORWARD dispatcher, as shown in the mapping list above (/WEB-INF/views/*, <dispatcher>FORWARD</dispatcher>), and the page now ends with the footer:

curl -si http://localhost:18080/g2-servlet/books/1
HTTP/1.1 200
Set-Cookie: JSESSIONID=5E99CAFF...; Path=/g2-servlet; HttpOnly; SameSite=Lax
Content-Type: text/html;charset=UTF-8
Content-Length: 178

<!DOCTYPE html>
<html>
<body>
<h1>Dune</h1>
<p>by Frank Herbert, 1965</p>
<p><a href="/g2-servlet/books">Back to the shelf</a></p>
</body>
</html>
<!-- rendered by Bookshelf -->

Layout frameworks such as SiteMesh use exactly this arrangement, and the Tiles & Page Layouts page of this section comes back to it. Two details of the wrapper above are worth copying into any real one: it implements only getWriter (a page that calls getOutputStream needs a second path), and it never sets Content-Length, because the length changes after the filter appends text.

A Filter That Blocks: Authentication and Authorization

A filter that does not call chain.doFilter ends the request. The AdminFilter protects /admin/* and the LoginServlet shows the safe login sequence (validate, rotate the session id, store the identity, redirect):

A filter that blocks anonymous requests
@WebFilter("/admin/*")
public class AdminFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession(false);
        if (session == null || session.getAttribute("user") == null) {
            resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Sign in first");
            return;
        }
        chain.doFilter(request, response);
    }
}
Login: rotate the session id first
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
    private static final Pattern USER = Pattern.compile("[a-z]{3,20}");

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String user = req.getParameter("user");
        if (user == null || !USER.matcher(user).matches()) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }
        req.getSession();
        req.changeSessionId();                                  // new id at the moment of login: no session fixation
        req.getSession().setAttribute("user", user);
        resp.sendRedirect(req.getContextPath() + "/admin/stats", HttpServletResponse.SC_SEE_OTHER);
    }
}
The protected resource
@WebServlet("/admin/stats")
public class AdminServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().println("admin area for " + req.getSession().getAttribute("user")
                + ", active sessions=" + getServletContext().getAttribute("activeSessions"));
    }
}
B=http://localhost:18080/g2-servlet; J=jar
curl -si $B/admin/stats | head -1
curl -si -c $J -b $J -d 'user=ROOT!' $B/login | head -1
curl -si -c $J -b $J -d 'user=alice' $B/login | sed -n '1p;3p'
curl -s  -c $J -b $J $B/admin/stats
HTTP/1.1 401
HTTP/1.1 400
HTTP/1.1 303
Location: /g2-servlet/admin/stats
admin area for alice, active sessions=1

A role filter has the same shape: after the identity check it compares a stored role set, or request.isUserInRole(role) when the container authenticates, and answers 403 instead of 401. A logging or auditing filter is the TimingFilter shape, and the content-rewriting filter is the FooterFilter above.

This identity check is a teaching stub. Real applications use container-managed authentication (<login-config>, <security-constraint>, HttpServletRequest.login), the Jakarta Security API or Spring Security, which handle credentials, password storage and role mapping; Security covers the container mechanisms (Servlet chapter 13).

Listeners: Application, Session and Request Events

Listeners are classes that the container calls when something happens in the web application. They are how an application starts and stops its own resources, counts sessions, and reacts to attribute changes.

Table 18. The listener interfaces (specification chapter 11)
Interface Events Typical use

ServletContextListener

contextInitialized, contextDestroyed

Load configuration and seed data, create thread pools and clients, register servlets and filters programmatically; stop them on shutdown

ServletContextAttributeListener

attribute added, removed, replaced on the context

Audit or react to application-scope changes

HttpSessionListener

sessionCreated, sessionDestroyed

Count active users, release per-user resources

HttpSessionAttributeListener

attribute added, removed, replaced on a session

Track what is stored in sessions

HttpSessionIdListener

sessionIdChanged

Audit changeSessionId() calls

HttpSessionActivationListener

sessionWillPassivate, sessionDidActivate

Implemented by a session attribute, to release and re-acquire non-serializable resources when a clustered or persistent container moves the session (for example a Tomcat manager configured to keep sessions across restarts)

HttpSessionBindingListener

valueBound, valueUnbound

Implemented by the attribute object itself (Cart in the example)

ServletRequestListener

requestInitialized, requestDestroyed

Per-request set-up and tear-down, for example thread-local cleanup

ServletRequestAttributeListener

attribute added, removed, replaced on a request

Debugging and auditing

AsyncListener

onStartAsync, onComplete, onTimeout, onError

Registered on an AsyncContext; clean up when an async cycle ends

Registration is by @WebListener, by a <listener> element, or programmatically with ServletContext.addListener. Listener instances are created once per declaration, so they follow the same thread-safety rules as servlets. The order in which the container calls listeners of the same type is the order of declaration in web.xml; annotation-declared ones are not ordered. A listener that throws during contextInitialized prevents the application from starting.

The AppListener of the example loads the catalogue from WEB-INF at startup, publishes the shared objects as context attributes, and registers a servlet programmatically. The SessionTracker counts sessions:

A context listener: seed data, shared objects and a programmatic servlet
@WebListener
public class AppListener implements ServletContextListener, ServletContextAttributeListener {

    @Override
    public void contextInitialized(ServletContextEvent event) {
        ServletContext ctx = event.getServletContext();
        EventLog log = new EventLog();
        ctx.setAttribute(EventLog.ATTRIBUTE, log);

        Catalogue catalogue = new Catalogue();
        try (InputStream in = ctx.getResourceAsStream("/WEB-INF/catalogue.csv");
             BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
            reader.lines().skip(1).map(l -> l.split(";")).forEach(
                    f -> catalogue.add(f[0], f[1], Integer.parseInt(f[2])));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
        ctx.setAttribute(Catalogue.ATTRIBUTE, catalogue);
        ctx.setAttribute("visits", new AtomicInteger());
        ctx.setAttribute("activeSessions", new AtomicInteger());

        // Programmatic registration is allowed here because this listener is annotated (not added programmatically).
        ctx.addServlet("ping", PingServlet.class).addMapping("/ping");
        log.add("initializer found filters: " + ctx.getAttribute("filtersFound"));
        log.add("contextInitialized: " + catalogue.all().size() + " books, title=" + ctx.getInitParameter("catalogue.title"));
    }

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        EventLog.of(event.getServletContext()).add("contextDestroyed");
    }

    @Override
    public void attributeAdded(ServletContextAttributeEvent event) {
        if (!"eventLog".equals(event.getName())) {
            EventLog.of(event.getServletContext()).add("context attribute added: " + event.getName());
        }
    }
}
Session, attribute and id-change events
@WebListener
public class SessionTracker implements HttpSessionListener, HttpSessionAttributeListener, HttpSessionIdListener {

    private static AtomicInteger active(HttpSessionEvent e) {
        return (AtomicInteger) e.getSession().getServletContext().getAttribute("activeSessions");
    }

    @Override
    public void sessionCreated(HttpSessionEvent e) {
        int n = active(e).incrementAndGet();
        EventLog.of(e.getSession().getServletContext()).add("sessionCreated, active=" + n);
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent e) {
        int n = active(e).decrementAndGet();
        EventLog.of(e.getSession().getServletContext()).add("sessionDestroyed, active=" + n);
    }

    @Override
    public void sessionIdChanged(HttpSessionEvent e, String oldSessionId) {
        EventLog.of(e.getSession().getServletContext()).add("sessionIdChanged (old id ends ..."
                + oldSessionId.substring(oldSessionId.length() - 4) + ")");
    }

    @Override
    public void attributeAdded(HttpSessionBindingEvent e) {
        EventLog.of(e.getSession().getServletContext()).add("session attribute added: " + e.getName());
    }

    @Override
    public void attributeRemoved(HttpSessionBindingEvent e) {
        EventLog.of(e.getSession().getServletContext()).add("session attribute removed: " + e.getName());
    }
}
The servlet that AppListener registers without annotation or web.xml
public class PingServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().println("pong from " + getServletName());
    }
}

The startup trace and the programmatic servlet:

curl -s http://localhost:18080/g2-servlet/log | head -5
curl -s http://localhost:18080/g2-servlet/ping
context attribute added: catalogue
context attribute added: visits
context attribute added: activeSessions
contextInitialized: 3 books, title=Bookshelf
LifecycleServlet.init, instance 1266317426
pong from ping

The order of the lines is informative. The ServletContextAttributeListener events for catalogue, visits and activeSessions come before the "contextInitialized" line, because the attributes are added inside contextInitialized and the log line is written at its end. LifecycleServlet.init runs afterwards because loadOnStartup servlets are initialized once all context listeners have run. The /ping servlet, which has no entry anywhere in the WAR, answers because addServlet(...).addMapping("/ping") ran during initialization; the spec allows the call because AppListener is declared in the application (annotated or in web.xml), whereas a listener that was itself added programmatically may not register servlets. Registration after initialization throws IllegalStateException.

Annotations Versus web.xml

Since Servlet 3.0 a web application does not need a web.xml at all: servlets, filters, listeners and multipart settings can be declared on the classes. The two mechanisms describe the same metadata, and the specification defines how they combine.

Table 19. Annotation and deployment-descriptor equivalents
Annotation web.xml element Notes

@WebServlet

<servlet> and <servlet-mapping>

Needs value or urlPatterns (not both). The default name is the fully qualified class name. loadOnStartup, initParams, asyncSupported are attributes. The class must extend HttpServlet

@WebFilter

<filter> and <filter-mapping>

urlPatterns, servletNames, dispatcherTypes, initParams, asyncSupported. No ordering attribute: use web.xml

@WebListener

<listener>

Listener type is detected from the interfaces the class implements

@WebInitParam

<init-param>

Used inside initParams

@MultipartConfig

<multipart-config>

Enables Part handling for a servlet

@ServletSecurity, @HttpConstraint

<security-constraint>

Covered by Security

@HandlesTypes

(none)

Only for a ServletContainerInitializer; always processed, even when metadata-complete is true

The merging rules (section 8.2.3) fit in five sentences. Annotations are read only from classes in WEB-INF/classes and in JARs in WEB-INF/lib. The web.xml of the application has the highest precedence and overrides conflicting values of annotations and fragments. metadata-complete="true" on the web-app element turns off annotation scanning and fragment processing. A servlet declared both ways under the same name is one servlet with the XML values winning, while the same class declared under different names is two instances, as the MappingServlet example does on purpose. When the order of filters, servlets (by load-on-startup) or listeners matters, it must come from web.xml; listeners of one kind are called in declaration order, and contextDestroyed, requestDestroyed and sessionDestroyed in reverse order.

The 6.1 schema and the document header that the example uses are:

The Servlet 6.1 descriptor header
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
         version="6.1">

Pluggability: Fragments and ServletContainerInitializer

Frameworks plug into a web application without touching web.xml. A library JAR in WEB-INF/lib can carry a META-INF/web-fragment.xml that contributes servlets, filters and listeners (ordered with <ordering> or the application’s <absolute-ordering>), static resources under META-INF/resources, and a ServletContainerInitializer registered in META-INF/services/jakarta.servlet.ServletContainerInitializer. The container calls onStartup(Set<Class<?>>, ServletContext) before any listener, passing every class that matches the @HandlesTypes annotation of the initializer. This is how Spring’s SpringServletContainerInitializer, Jasper and Jakarta Faces start themselves, and why a JAR on the classpath can change the behaviour of an application.

The example has a tiny initializer, registered through the services file, that records which filters the container found:

A ServletContainerInitializer that reports the Filter classes it was handed
@HandlesTypes(Filter.class)
public class ScanInitializer implements ServletContainerInitializer {
    @Override
    public void onStartup(Set<Class<?>> classes, ServletContext ctx) {
        List<String> names = classes == null ? List.of()
                : classes.stream().map(Class::getSimpleName).sorted().toList();
        ctx.setAttribute("filtersFound", names);
    }
}
WEB-INF/classes/META-INF/services/jakarta.servlet.ServletContainerInitializer:
org.example.bookshelf.ScanInitializer
initializer found filters: [AdminFilter, DispatchTraceFilter, FooterFilter, TimingFilter, TrimFilter]
contextInitialized: 3 books, title=Bookshelf

The first line is written by AppListener from the attribute the initializer set: the initializer ran first and saw the five filter classes of the WAR, annotated or not.

RequestDispatcher: Forward, Include and Named Dispatch

A RequestDispatcher hands the current request to another resource inside the same container: a servlet, a JSP, a static file. It is the mechanism behind the Model 2 pattern (built out in MVC with Servlets & JSP): a controller servlet prepares the model as request attributes and forwards to a JSP that renders it (the BookServlet of this page does so). It comes from request.getRequestDispatcher(path) (the path may be relative to the current servlet), ServletContext.getRequestDispatcher(path) (must start with /) or ServletContext.getNamedDispatcher(name) (a servlet name from web.xml or @WebServlet(name=...)). A path can carry a query string, and the container merges the new parameters with the original ones, the new values first.

Table 20. forward, include and sendRedirect compared
forward include sendRedirect

What the client sees

one response, original URL

one response, original URL

a 3xx and a second request to a new URL

Request and attributes

same request; model attributes survive

same request; model attributes survive

lost (new request); use the session or a flash mechanism

Response handling

uncommitted output is discarded and the response is committed and closed when forward returns

the target appends to the output but cannot change status or headers (calls are ignored)

no body of its own

Path methods

describe the target

still describe the including request

not applicable

Request attributes added

jakarta.servlet.forward.* with the original path elements

jakarta.servlet.include.* with the target’s

none

Allowed when committed

throws IllegalStateException

yes

throws IllegalStateException

Typical use

controller to view

page fragments, headers and footers

after a state-changing POST

The DispatchServlet and FragmentServlet of the example exercise every variation. FragmentServlet prints what it sees:

The dispatch target: it prints the path elements and the forward and include attributes
@WebServlet(name = "fragment", urlPatterns = "/fragment/*")
public class FragmentServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.setHeader("X-Ignored", "set-by-fragment");     // ignored when this servlet is being included
        PrintWriter out = resp.getWriter();
        out.println("-- fragment: dispatcher=" + req.getDispatcherType() + " uri=" + req.getRequestURI()
                + " servletPath=" + req.getServletPath() + " pathInfo=" + req.getPathInfo()
                + " query=" + req.getQueryString());
        for (String name : List.of(RequestDispatcher.FORWARD_REQUEST_URI, RequestDispatcher.FORWARD_QUERY_STRING,
                RequestDispatcher.FORWARD_MAPPING, RequestDispatcher.INCLUDE_REQUEST_URI,
                RequestDispatcher.INCLUDE_QUERY_STRING, RequestDispatcher.INCLUDE_SERVLET_PATH)) {
            Object value = req.getAttribute(name);
            out.println("   " + name + " = " + (value instanceof jakarta.servlet.http.HttpServletMapping m
                    ? m.getMappingMatch() + ":" + m.getPattern() : value));
        }
        out.println("   from parameter=" + req.getParameter("from"));
    }
}
Forward, include, named dispatcher and misuse
@WebServlet("/dispatch")
public class DispatchServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        String mode = String.valueOf(req.getParameter("mode"));
        switch (mode) {
            case "forward" -> {
                resp.getWriter().println("this text is discarded: forward clears the uncommitted buffer");
                req.getRequestDispatcher("/fragment/view?from=forward").forward(req, resp);
            }
            case "include" -> {
                PrintWriter out = resp.getWriter();
                out.println("before include");
                req.getRequestDispatcher("/fragment/view?from=include").include(req, resp);
                out.println("after include, status=" + resp.getStatus() + " header X-Ignored=" + resp.getHeader("X-Ignored"));
            }
            case "named" -> getServletContext().getNamedDispatcher("fragment").forward(req, resp);
            case "forward-after-commit" -> {
                resp.getWriter().println("committed");
                resp.flushBuffer();
                try {
                    req.getRequestDispatcher("/fragment/view").forward(req, resp);
                } catch (IllegalStateException e) {
                    resp.getWriter().println("forward after commit -> IllegalStateException");
                }
            }
            case "missing" -> {
                RequestDispatcher d = req.getRequestDispatcher("/does/not/exist");
                resp.getWriter().println("dispatcher for an unknown path is never null here: " + (d != null));
            }
            default -> resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "modes: forward, include, named, forward-after-commit, missing");
        }
    }
}
B=http://localhost:18080/g2-servlet
curl -s "$B/dispatch?mode=forward&p=1"
curl -s "$B/dispatch?mode=include"
curl -s "$B/dispatch?mode=named&x=1"
curl -s "$B/dispatch?mode=forward-after-commit"
-- fragment: dispatcher=FORWARD uri=/g2-servlet/fragment/view servletPath=/fragment pathInfo=/view query=from=forward
   jakarta.servlet.forward.request_uri = /g2-servlet/dispatch
   jakarta.servlet.forward.query_string = mode=forward&p=1
   jakarta.servlet.forward.mapping = EXACT:/dispatch
   jakarta.servlet.include.request_uri = null
   jakarta.servlet.include.query_string = null
   jakarta.servlet.include.servlet_path = null
   from parameter=forward
before include
-- fragment: dispatcher=INCLUDE uri=/g2-servlet/dispatch servletPath=/dispatch pathInfo=null query=mode=include
   jakarta.servlet.forward.request_uri = null
   jakarta.servlet.forward.query_string = null
   jakarta.servlet.forward.mapping = null
   jakarta.servlet.include.request_uri = /g2-servlet/fragment/view
   jakarta.servlet.include.query_string = from=include
   jakarta.servlet.include.servlet_path = /fragment
   from parameter=include
after include, status=200 header X-Ignored=null
-- fragment: dispatcher=FORWARD uri=/g2-servlet/dispatch servletPath=/dispatch pathInfo=null query=mode=named&x=1
   jakarta.servlet.forward.request_uri = null
   ...
   from parameter=null
committed
forward after commit -> IllegalStateException

The four outputs show the differences in the table. In the forward, the path methods describe the target (/fragment/view), the original request is in the forward.* attributes, the text the dispatcher wrote before forwarding is gone, and from is forward, the new query parameter. In the include, the opposite holds: getRequestURI() is still /g2-servlet/dispatch, the target’s path is in the include.* attributes, and the header the fragment tried to set (X-Ignored) never arrived. A resource that must know its real path when it can be included therefore reads the attribute first and falls back to the method. In the named dispatch the target sees the original path and no forward attributes at all, because no path was used. Forwarding after a flush is rejected.

The filter trace of a forward and of an include (X-Trace header, outer/inner use the default REQUEST mapping):

forward:  outer before REQUEST /g2-servlet/dispatch
          inner before REQUEST /g2-servlet/dispatch
          DispatchTrace saw REQUEST /g2-servlet/dispatch
          DispatchTrace saw FORWARD /g2-servlet/fragment/view
          by-servlet-name before FORWARD /g2-servlet/fragment/view
          by-servlet-name after (188 us)
          inner after (399 us)
          outer after (414 us)
include:  outer before REQUEST /g2-servlet/dispatch
          inner before REQUEST /g2-servlet/dispatch
          DispatchTrace saw REQUEST /g2-servlet/dispatch
          DispatchTrace saw INCLUDE /g2-servlet/dispatch
          inner after (1336 us)
          outer after (1362 us)

outer and inner ran once, for the client request. DispatchTraceFilter, which lists all dispatcher types, saw the forward and the include, and the filter mapped by servlet name and for FORWARD joined only when the forward reached fragment. The include is not seen by by-servlet-name because that mapping lists only REQUEST and FORWARD. Note the URI printed for the include: it is the including request’s.

URL Mapping Rules

The container maps a request to a servlet in two steps. It first picks the web application with the longest context path that matches the start of the URL. Within the application it removes the context path and any path parameters (such as ;jsessionid=...), decodes the rest as UTF-8, and applies these rules in order, stopping at the first match:

Table 21. The mapping algorithm (section 12.1) and the URL pattern forms (section 12.2)
Step Pattern form Rule

1

exact: a string such as /map/exact

The whole path equals the pattern

2

path prefix: /map/*

The longest prefix match, one directory at a time; the pattern matches /map, /map/ and everything below it

3

extension: *.report

The last segment ends in .report

4

default: /

The "default" servlet of the application (Tomcat’s DefaultServlet serves static files unless you replace it)

special

the empty string ""

Exactly the context root, with or without the trailing slash; the servlet path is the empty string

special

the implicit *.jsp

When the container has a JSP engine, *.jsp maps to it, unless the application defines its own *.jsp mapping

Matching is case sensitive. Patterns that mix wildcards (/WEB-INF/views/*.jsp) are invalid, and Tomcat refused to deploy the example when it contained one: the log said Invalid <url-pattern> [/WEB-INF/views/*.jsp] in servlet mapping. If the same pattern maps to two servlets in the effective configuration, deployment fails.

HttpServletRequest.getHttpServletMapping() (Servlet 4.0) reports how the match happened. MappingServlet prints it, and the WAR declares the class three times in web.xml (once per pattern), so three instances exist:

Reporting the mapping that selected the servlet
public class MappingServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        HttpServletMapping m = req.getHttpServletMapping();
        resp.setContentType("text/plain;charset=UTF-8");
        PrintWriter out = resp.getWriter();
        out.println("servlet=" + getServletName() + " instance=" + System.identityHashCode(this));
        out.println("match=" + m.getMappingMatch() + " pattern='" + m.getPattern() + "' matchValue='" + m.getMatchValue() + "'");
        out.println("requestURI=" + req.getRequestURI());
        out.println("contextPath='" + req.getContextPath() + "' servletPath='" + req.getServletPath()
                + "' pathInfo=" + req.getPathInfo());
    }
}
web.xml: three declarations of MappingServlet and the first mapping (the others map /map/* and *.report)
<servlet>
  <servlet-name>mapping-exact</servlet-name>
  <servlet-class>org.example.bookshelf.MappingServlet</servlet-class>
</servlet>
<servlet>
  <servlet-name>mapping-prefix</servlet-name>
  <servlet-class>org.example.bookshelf.MappingServlet</servlet-class>
</servlet>
<servlet>
  <servlet-name>mapping-extension</servlet-name>
  <servlet-class>org.example.bookshelf.MappingServlet</servlet-class>
</servlet>
<servlet-mapping>
  <servlet-name>mapping-exact</servlet-name>
  <url-pattern>/map/exact</url-pattern>
</servlet-mapping>
B=http://localhost:18080/g2-servlet
curl -s $B/map/exact ; curl -s $B/map/a/b ; curl -s $B/reports/q1.report
servlet=mapping-exact instance=223862864
match=EXACT pattern='/map/exact' matchValue='map/exact'
requestURI=/g2-servlet/map/exact
contextPath='/g2-servlet' servletPath='/map/exact' pathInfo=null
servlet=mapping-prefix instance=1832380977
match=PATH pattern='/map/*' matchValue='a/b'
requestURI=/g2-servlet/map/a/b
contextPath='/g2-servlet' servletPath='/map' pathInfo=/a/b
servlet=mapping-extension instance=2010951165
match=EXTENSION pattern='*.report' matchValue='reports/q1'
requestURI=/g2-servlet/reports/q1.report
contextPath='/g2-servlet' servletPath='/reports/q1.report' pathInfo=null

The three servletPath and pathInfo pairs are the key to reading paths: for an exact or extension match the servlet path is the whole path and the path info is null; for a prefix match the servlet path is the prefix and the path info is the rest. getContextPath() + getServletPath() + getPathInfo() rebuilds the URI. A controller mapped to /books/* reads the id from the path info (/books/2 gives /2), as BookServlet does.

The complete outcome of many requests, on the deployed example:

Table 22. Observed mapping outcomes (Tomcat 11.0.26)
Request path Handled by Why

/map/exact

mapping-exact

exact match wins over the /map/* prefix

/map/exact/more

mapping-prefix

not exact; longest prefix /map/*, match value exact/more

/map/q1.report

mapping-prefix

the prefix rule precedes the extension rule

/reports/q1.report, /index.report

mapping-extension

no exact or prefix match; extension *.report

/ or the bare context path

HomeServlet

CONTEXT_ROOT match, match value '', path info /

/hello.jsp

Tomcat’s JSP servlet

implicit *.jsp mapping, dispatcher=REQUEST servletPath=/hello.jsp

/about

the about servlet

declared with <jsp-file>; the JSP itself is under WEB-INF and unreachable

/ping

ping

registered programmatically; /ping/x is 404 because the pattern is exact

/WEB-INF/web.xml, /WEB-INF/views/books.jsp

nobody (404)

the container never serves WEB-INF to clients

/nothing/here

the default servlet, 404

no mapping, falls to rule 4

WAR Structure, WEB-INF and Welcome Files

A web application is a directory tree, usually packaged as a WAR (a JAR with the .war extension). The public part is the tree below the root; everything under WEB-INF is private to the container.

The layout of the example WAR (jar tf g2-servlet.war, abridged)
/                                  public: served as static files or mapped to servlets
├── hello.jsp                        a JSP in the public tree (reachable, implicit *.jsp mapping)
├── docs/index.html                  a directory with a welcome file
├── errors/readme.txt                a plain static file
├── META-INF/MANIFEST.MF
└── WEB-INF/                         never served to a client
    ├── web.xml                      the deployment descriptor
    ├── catalogue.csv                private data read with getResourceAsStream
    ├── views/books.jsp              JSP views, reachable only through a dispatcher
    ├── views/book.jsp
    ├── views/about.jsp
    ├── classes/org/example/bookshelf/*.class
    ├── classes/META-INF/services/jakarta.servlet.ServletContainerInitializer
    └── lib/jakarta.servlet.jsp.jstl-api-3.0.2.jar, jakarta.servlet.jsp.jstl-3.0.1.jar
Table 23. What lives where
Location Rule

Application root

Public static files and JSPs; a request for /docs/index.html is answered from here

WEB-INF/web.xml

The deployment descriptor, optional since Servlet 3.0

WEB-INF/classes

Compiled classes and resources on the application’s class path, found before JARs; annotations are scanned here

WEB-INF/lib/*.jar

Libraries, including JARs with fragments, TLDs and META-INF/resources; JARs are scanned for annotations

WEB-INF (anything else)

Private. A request for /WEB-INF/web.xml gives 404 even though the file exists, but a forward from a servlet may target it; this is why views belong here

META-INF

Metadata of the archive; META-INF/resources in a library JAR is served as if it were in the root

The Servlet API JAR

Not in WEB-INF/lib: the container provides it, and the Maven scope provided keeps it out of the WAR (the listing above has only the two JSTL JARs)

The test that confirmed the privacy of WEB-INF and the behaviour of directories and welcome files:

B=http://localhost:18080/g2-servlet
curl -s -o /dev/null -w '%{http_code}\n' $B/WEB-INF/web.xml
curl -s -o /dev/null -w '%{http_code}\n' $B/WEB-INF/views/books.jsp
curl -si $B/docs | head -2
curl -s $B/docs/
404
404
HTTP/1.1 302
Location: /g2-servlet/docs/
<!DOCTYPE html>
<html><body><h1>Docs welcome page</h1></body></html>

A welcome file answers a request for a directory. The web.xml lists them in order:

web.xml: welcome files
<welcome-file-list>
  <welcome-file>index.html</welcome-file>
  <welcome-file>default.jsp</welcome-file>
</welcome-file-list>

For /docs (without the slash) the container redirects to /docs/, then appends index.html, finds a static file, and serves it. If no static file matches, it tries the names as servlet mappings (default.jsp), and if nothing matches, the container decides: a listing or a 404. Because the example maps the empty pattern to HomeServlet, the context root is answered by that servlet and not by a welcome file, a case where the explicit mapping wins.

Deployment to Tomcat is a copy: drop g2-servlet.war into webapps/ (the context path is the file name without .war), use the Manager application, or set unpackWARs/autoDeploy on the host. The Getting Started page of this section covers the Maven, Jetty and Docker routes. For the javax to jakarta change, Tomcat 10 and later do not run javax.servlet code; the Tomcat migration tool converts old WARs (see What’s Changed & Migration).

A Tour of the web.xml Elements the Bookshelf Uses

Table 24. The deployment descriptor elements of the example
Element Purpose In the example

<display-name>

Name shown by tools; also ServletContext.getServletContextName()

"Bookshelf servlet fundamentals"

<context-param>

Application-wide read-only parameter, ServletContext.getInitParameter

catalogue.title, read in JSP as ${initParam['catalogue.title']}

<request-character-encoding>, <response-character-encoding>

Default encodings (Servlet 4.0 and later)

UTF-8 for both

<filter>, <filter-mapping>

Declare filters, give them an order and dispatcher types

outer, inner, by-servlet-name, trim, footer

<listener>

Declare listeners and give them an order (@WebListener is the alternative)

annotations in the example

<servlet>, <servlet-mapping>

Declare a servlet by class or by <jsp-file>, map it to patterns

mapping-*, and about from /WEB-INF/views/about.jsp

<jsp-config>

JSP property groups (encoding, whitespace trimming, scripting) and TLD mapping (Configuration and Property Groups); the Servlet 2.3 <taglib> directly under <web-app> moved here in 2.4

page-encoding UTF-8 and trim-directive-whitespaces for the views

<session-config>

Timeout (minutes), cookie attributes, tracking modes

30 minutes, HttpOnly, SameSite=Lax, cookies only

<mime-mapping>

Extension to MIME type

rss to application/rss+xml

<welcome-file-list>

Ordered welcome files

index.html, default.jsp

<error-page>

Status code or exception type to a resource

next section

<multipart-config>, <security-constraint>, <login-config>, <resource-ref>, <env-entry>

Upload limits, container security, JNDI references (data sources and others)

upload is annotation-driven; security and JNDI belong to the JSP security page and to the persistence pages

Precompiling JSPs at build time (JspC, with the Maven and Jetty plugins) and the Jasper options that map a precompiled page to a URL are covered by Jasper Configuration & Precompilation; from the servlet point of view a precompiled JSP is simply a servlet class plus a <servlet-mapping>, exactly like about.

The effect of the <jsp-config> group is visible in the views: the same books.jsp produced blank lines for every directive and every c:forEach iteration before the group was added, and none afterwards.

Error Pages

When a servlet calls sendError or lets an exception escape, the container renders an error response. The <error-page> element replaces the container’s default report with an application resource (the JSP side, with the errorPage attribute and isErrorPage, is in Error Handling; Struts maps exceptions to results instead, see Exception Handling and Global Results):

web.xml: error pages
<error-page>
  <error-code>404</error-code>
  <location>/errors/generic</location>
</error-page>
<error-page>
  <exception-type>java.sql.SQLException</exception-type>
  <location>/errors/database</location>
</error-page>
<error-page>
  <location>/errors/generic</location>
</error-page>

The matching rules (section 10.9.2) are:

  1. sendError(status): an <error-code> entry for that status.

  2. An exception that escapes: the <exception-type> entry whose class is the closest in the class hierarchy.

  3. If none matches and the exception is a ServletException, a second pass with getRootCause().

  4. Otherwise the default entry, an <error-page> without code and type.

  5. Otherwise the container’s own page, with status 500 for uncaught exceptions.

The error page is invoked by an ERROR dispatch that always uses the HTTP method GET, whatever the original method was, with the request path and attributes set as for a forward. Servlet 6.1 added jakarta.servlet.error.method for the original method and jakarta.servlet.error.query_string.

Table 25. The error request attributes (Table 10-1 plus the 6.1 additions)
Attribute Type Content

jakarta.servlet.error.status_code

Integer

The status code

jakarta.servlet.error.exception_type

Class

The exception class (kept for old code; the exception attribute is more useful)

jakarta.servlet.error.message

String

The message passed to sendError or the exception message

jakarta.servlet.error.exception

Throwable

The exception that escaped

jakarta.servlet.error.request_uri

String

The URI of the failing request

jakarta.servlet.error.query_string

String

6.1: the query string of the failing request

jakarta.servlet.error.servlet_name

String

The servlet that failed

jakarta.servlet.error.method

String

6.1: the original HTTP method

ErrorServlet prints them, and BoomServlet produces each kind of failure:

An error page: always GET, reads the error attributes
@WebServlet(name = "errors", urlPatterns = {"/errors/generic", "/errors/database"})
public class ErrorServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        Integer status = (Integer) req.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        resp.setContentType("text/plain;charset=UTF-8");
        if (status != null) {
            resp.setStatus(status);                         // an error page keeps the original status unless you set it
        }
        PrintWriter out = resp.getWriter();
        out.println("error page " + req.getServletPath() + " dispatcher=" + req.getDispatcherType()
                + " handled-as=" + req.getMethod());
        out.println("status=" + status);
        out.println("message=" + req.getAttribute(RequestDispatcher.ERROR_MESSAGE));
        out.println("exception_type=" + req.getAttribute(RequestDispatcher.ERROR_EXCEPTION_TYPE));
        out.println("exception=" + req.getAttribute(RequestDispatcher.ERROR_EXCEPTION));
        out.println("request_uri=" + req.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));
        out.println("query_string=" + req.getAttribute(RequestDispatcher.ERROR_QUERY_STRING));
        out.println("servlet_name=" + req.getAttribute(RequestDispatcher.ERROR_SERVLET_NAME));
        out.println("original method=" + req.getAttribute(RequestDispatcher.ERROR_METHOD));
    }
}
Four ways to fail
@WebServlet("/boom")
public class BoomServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        switch (String.valueOf(req.getParameter("kind"))) {
            case "status" -> resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no entry");
            case "runtime" -> throw new IllegalStateException("catalogue exploded");
            case "wrapped" -> throw new ServletException("lookup failed", new SQLException("db down"));
            case "committed" -> {
                resp.getWriter().println("partial page");
                resp.flushBuffer();
                throw new IllegalStateException("too late for an error page");
            }
            default -> resp.getWriter().println("kinds: status, runtime, wrapped, committed");
        }
    }
}
B=http://localhost:18080/g2-servlet
for k in status runtime wrapped committed; do curl -si -X POST "$B/boom?kind=$k"; done
curl -si $B/nothing/here
HTTP/1.1 403
error page /errors/generic dispatcher=ERROR handled-as=GET
status=403
message=no entry
exception_type=null
exception=null
request_uri=/g2-servlet/boom
query_string=kind=status
servlet_name=org.example.bookshelf.BoomServlet
original method=POST
HTTP/1.1 500
error page /errors/generic dispatcher=ERROR handled-as=GET
status=500
message=
exception_type=null
exception=java.lang.IllegalStateException: catalogue exploded
request_uri=/g2-servlet/boom
query_string=kind=runtime
servlet_name=org.example.bookshelf.BoomServlet
original method=POST
HTTP/1.1 500
error page /errors/database dispatcher=ERROR handled-as=GET
status=500
message=lookup failed
exception_type=class java.sql.SQLException
exception=java.sql.SQLException: db down
request_uri=/g2-servlet/boom
query_string=kind=wrapped
servlet_name=org.example.bookshelf.BoomServlet
original method=POST
HTTP/1.1 200
Transfer-Encoding: chunked

partial page
curl: (18) transfer closed with outstanding read data remaining
HTTP/1.1 404
error page /errors/generic dispatcher=ERROR handled-as=GET
status=404
message=The requested resource [/g2-servlet/nothing/here] is not available
exception_type=null
exception=null
request_uri=/g2-servlet/nothing/here
query_string=null
servlet_name=default
original method=GET

The outputs teach five things. The POST that failed was handled as GET with the original method in error.method. The servlet_name identifies the component that failed, including default for a missing resource. For an escaped runtime exception that matched the default entry, Tomcat 11 supplied the exception attribute but left exception_type null and the message empty, so an error page should read error.exception. A ServletException wrapping a SQLException reached the /errors/database page through the second, root-cause pass; the status was 500 in both. And when the response was already committed (committed), no error page can run: the client got the partial page and a truncated chunked body (curl exits with 18), which is the visible symptom of an exception after a flush.

When the same web.xml also declared <exception-type>java.lang.Throwable</exception-type>, the result for wrapped changed. That entry matches every exception at the first pass, so the wrapped case never reached /errors/database and produced /errors/generic with exception_type=class java.sql.SQLException: the root-cause pass is skipped whenever a broad type is declared. A catch-all belongs in the entry without code and type.

The error page mechanism does not apply to exceptions thrown inside a RequestDispatcher call or to errors that the calling filter handles itself, so a filter or servlet that dispatches gets the chance to deal with the error first. The error response is also the place to avoid leaking information: Tomcat’s default report shows the exception and the server version unless ErrorReportValve is configured (showReport, showServerInfo), so applications supply their own page and never print stack traces to users.

File Upload: @MultipartConfig and Part

A multipart/form-data request carries several parts, text fields and files. Since Servlet 3.0 the API reads them with getParts() and getPart(name), which return Part objects; no third-party library is needed. A servlet must opt in, with @MultipartConfig or <multipart-config>, or getPart throws.

Table 26. The @MultipartConfig attributes (sizes in bytes since the 6.1 Javadoc clarification, issue 463)
Attribute Meaning

location

Directory for temporary files and for Part.write with a relative name; the default is the container’s temp directory

fileSizeThreshold

Size above which a part is spooled to disk instead of memory

maxFileSize

Maximum size of one part; the default is unlimited, so always set it

maxRequestSize

Maximum size of the whole request; unlimited by default, so always set it

Table 27. The Part API
Method Notes

getName()

The form field name

getSubmittedFileName()

The client’s file name, a hint for display only; browsers send only the file name, but a hostile client can send a path

getContentType()

The client’s claim, not proof of the content

getSize(), getInputStream()

Size and the content stream

getHeader(name), getHeaders(name)

The part headers

write(fileName), delete()

Persist to the location or discard the temporary file

UploadServlet accepts a book cover. It checks the request type, applies the limits through the annotation, treats text fields as ordinary parameters, checks the declared type against an allowlist, ignores the client’s name for storage, and copies the stream to a file name chosen by the server:

A cover upload with limits and a server-chosen file name
@WebServlet("/upload")
@MultipartConfig(location = "", fileSizeThreshold = 8 * 1024, maxFileSize = 64 * 1024, maxRequestSize = 128 * 1024)
public class UploadServlet extends HttpServlet {

    private static final Set<String> TYPES = Set.of("image/png", "image/jpeg");

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        String contentType = req.getContentType();
        if (contentType == null || !contentType.toLowerCase().startsWith("multipart/form-data")) {
            resp.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "multipart/form-data expected");
            return;
        }
        Part cover;
        String title;
        try {
            title = req.getParameter("title");               // text fields arrive as ordinary parameters
            cover = req.getPart("cover");
        } catch (IllegalStateException e) {                  // size limits exceeded
            resp.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
            resp.getWriter().println("upload rejected: too large");
            return;
        }
        if (cover == null || cover.getSize() == 0 || !TYPES.contains(cover.getContentType())) {
            resp.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "send a PNG or JPEG in the cover part");
            return;
        }
        String submitted = cover.getSubmittedFileName();     // the client's name: display only, never a file path
        String display = submitted == null ? "" : Paths.get(submitted.replace('\\', '/')).getFileName().toString();
        File dir = (File) getServletContext().getAttribute("jakarta.servlet.context.tempdir");
        Path target = dir.toPath().resolve(UUID.randomUUID() + ".png");     // the server picks the stored name
        try (InputStream in = cover.getInputStream()) {
            Files.copy(in, target);
        }
        resp.getWriter().println("title=" + title + " cover=" + display + " type=" + cover.getContentType()
                + " bytes=" + cover.getSize() + " stored=" + Files.exists(target));
        Files.delete(target);
    }
}
B=http://localhost:18080/g2-servlet
curl -si -F title=Dune -F 'cover=@cover.png;type=image/png;filename=../../etc/dune cover.png' $B/upload
curl -si -F title=Dune -F 'cover=@note.txt;type=text/plain' $B/upload | head -1
curl -si -F title=Dune -F 'cover=@big.png;type=image/png' $B/upload | sed -n '1p;/^$/,$p'
curl -si -d title=x $B/upload | head -1
HTTP/1.1 200
...
title=Dune cover=dune cover.png type=image/png bytes=69 stored=true
HTTP/1.1 415
HTTP/1.1 413

upload rejected: too large
HTTP/1.1 415

The hostile file name ../../etc/dune cover.png was reduced to dune cover.png for display and never used as a path. The text file was refused with 415, the 100 KB image exceeded maxFileSize (64 KB) and the container threw IllegalStateException, which the servlet turned into 413. The last request is not multipart at all. Before the content-type check was added to the servlet, Tomcat answered it with a 500 whose exception was a ServletException wrapping InvalidContentTypeException; the check turns a client mistake into the 415 a client deserves. Uploaded content is still untrusted: verify the magic bytes or decode the image, store it outside the web root, serve it with a fixed content type and X-Content-Type-Options: nosniff, and never execute or include it.

Asynchronous Processing in Brief

A servlet that waits for a slow resource (a remote service, a queue) can release the container thread instead of blocking it. It calls startAsync(), returns, and later completes the response from another thread with AsyncContext.complete() or re-dispatches with AsyncContext.dispatch. Both the servlet and every filter in its path must declare asyncSupported (annotation attribute or <async-supported>), or startAsync throws IllegalStateException. The example’s three timing filters declare it in web.xml.

Releasing the container thread while a slow task runs
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String requestThread = Thread.currentThread().getName();
        AsyncContext async = req.startAsync();               // the container thread is released when doGet returns
        async.setTimeout(5_000);
        async.start(() -> {
            try {
                Thread.sleep(200);                            // stands in for a slow remote call
                resp.setContentType("text/plain;charset=UTF-8");
                resp.getWriter().println("request thread=" + requestThread + ", worker thread="
                        + Thread.currentThread().getName());
            } catch (IOException | InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                async.complete();                             // always complete, or the request hangs until timeout
            }
        });
    }
}
curl -s http://localhost:18080/g2-servlet/async
request thread=http-nio-18080-exec-37, worker thread=http-nio-18080-exec-30

The request thread and the worker thread are different threads of the container’s pool. The finally block that calls complete() is not optional: a response that is neither completed nor dispatched stays open until the timeout. Non-blocking I/O (ReadListener, WriteListener), HTTP trailers and protocol upgrade (WebSocket) build on the same machinery and are outside this page; HTTP/2 server push, available through newPushBuilder(), is deprecated in 6.1 and optional for containers. For new applications with high concurrency on Java 21, virtual threads and plain blocking servlets are an alternative worth measuring before reaching for the async API.

The Bookshelf End to End: Adding a Book

The pieces above combine in the most common servlet task: a form that creates a record. The whole flow, as one client sees it, touches almost every section of this page: a filter chain, a controller servlet, a session with a CSRF token, a forward to a JSP, a filter wrapper, validation, a 303 redirect and the error pages.

Table 28. The request journey of "add a book"
Step Request What happens

1

GET /books

outer and inner run (REQUEST); BookServlet.doGet creates the session and a CSRF token in it (the SessionTracker logs sessionCreated and session attribute added: csrf), puts the catalogue in the request, and forwards to /WEB-INF/views/books.jsp; FooterFilter (FORWARD) appends the footer

2

POST /books with the token

BookServlet.doPost compares the token in constant time, validates, adds the book to the shared Catalogue and answers 303 with Location: /g2-servlet/books/4

3

GET /books/4

Path info /4 selects the book; the JSP renders it

4

POST /books without the token

sendError(403); the error page mechanism renders /errors/generic as an ERROR dispatch

5

POST /books with an empty title

sendError(400)

6

GET /books with Accept: application/json

Same servlet, same data, JSON with Vary: Accept

B=http://localhost:18080/g2-servlet; J=jar
curl -si -c $J -b $J -H 'X-Trace: 1' $B/books | sed -n 1,3p
T=$(curl -s -c $J -b $J $B/books | grep -o 'name="csrf" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -si -c $J -b $J -d "csrf=$T&title=Hyperion&author=Dan+Simmons&year=1989" $B/books | sed -n 1,3p
curl -s -b $J $B/books/4 | sed -n 4,6p
curl -si -b $J -d 'title=X&author=Y&year=2000' $B/books | sed -n 1p
curl -si -c $J -b $J -d "csrf=$T&title=&author=Y&year=2000" $B/books | sed -n 1p
curl -s -H 'Accept: application/json' $B/books | tail -c 150
HTTP/1.1 200
Set-Cookie: JSESSIONID=38A8141E...; Path=/g2-servlet; HttpOnly; SameSite=Lax
Vary: Accept
HTTP/1.1 303
Location: /g2-servlet/books/4
Content-Length: 0
<h1>Hyperion</h1>
<p>by Dan Simmons, 1989</p>
<p><a href="/g2-servlet/books">Back to the shelf</a></p>
HTTP/1.1 403
HTTP/1.1 400
,{"id":3,"title":"The Left Hand of Darkness","author":"Ursula K. Le Guin","year":1969},{"id":4,"title":"Hyperion","author":"Dan Simmons","year":1989}]

The trace of step 1 (with the X-Trace header) is the filter chain the figure showed:

outer before REQUEST /g2-servlet/books
inner before REQUEST /g2-servlet/books
sessionCreated, active=4
session attribute added: csrf
inner after (1009 us)
outer after (1301 us)

The view that step 1 forwards to is books.jsp, listed below: it prints each title with c:out (HTML escaping), builds URLs from ${pageContext.request.contextPath}, and carries the token in a hidden field. The BookServlet code was listed in the request section, and the save path (doPost) is:

BookServlet.doPost: token check, validation, Post/Redirect/Get
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        HttpSession session = req.getSession(false);
        String expected = session == null ? null : (String) session.getAttribute("csrf");
        String actual = req.getParameter("csrf");
        if (expected == null || actual == null
                || !MessageDigest.isEqual(expected.getBytes(), actual.getBytes())) {
            resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Missing or invalid CSRF token");
            return;
        }
        String title = req.getParameter("title");
        String author = req.getParameter("author");
        int year;
        try {
            year = Integer.parseInt(req.getParameter("year"));
        } catch (NumberFormatException e) {
            year = -1;
        }
        if (title == null || title.isBlank() || author == null || author.isBlank() || year < 1450) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "title, author and a year >= 1450 are required");
            return;
        }
        Book created = Catalogue.of(getServletContext()).add(title.strip(), author.strip(), year);
        // Post/Redirect/Get: 303 tells the client to follow with a GET, so a refresh never re-posts the form.
        resp.sendRedirect(req.getContextPath() + "/books/" + created.id(), HttpServletResponse.SC_SEE_OTHER);
    }
books.jsp: the view the controller forwards to
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html>
<body>
<h1>${initParam['catalogue.title']}</h1>
<ul>
<c:forEach var="b" items="${books}">
  <li><a href="${pageContext.request.contextPath}/books/${b.id}"><c:out value="${b.title}"/></a>
      by <c:out value="${b.author}"/> (${b.year})</li>
</c:forEach>
</ul>
<form method="post" action="${pageContext.request.contextPath}/books">
  <input type="hidden" name="csrf" value="<c:out value='${csrf}'/>">
  <input name="title"> <input name="author"> <input name="year" size="4">
  <button>Add</button>
</form>
</body>
</html>

Pitfalls and Error Names

Table 29. Servlet pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

Counters or user data mix between users

Per-request data in servlet or filter fields: one instance serves all threads

Keep state in local variables, request, session or thread-safe application objects; make fields immutable or atomic

getParameter returns null for a form field

The body was read first with getReader() or getInputStream()

Read parameters first, or wrap the request and serve the cached body

ñ instead of ñ (observed)

Request charset undefined, container default ISO-8859-1, or setCharacterEncoding called after the first parameter read

<request-character-encoding>UTF-8, or set it first in a filter; <response-character-encoding>

Header or status change has no effect (observed)

The response was already committed by a flush, a full buffer or a forward

Set headers before writing; use resetBuffer/reset only before commit

IllegalStateException from sendError, sendRedirect, forward, getSession, reset (observed)

The response is committed

Create the session and decide errors before writing; return after sendError or sendRedirect

IllegalStateException from getWriter or getOutputStream

A servlet or JSP mixed the writer and the stream

Use one; set the type before the first call

Empty page with Content-Length: 0 after a response-wrapping filter (observed)

The filter was mapped to REQUEST, but a forward closes the real response before the filter’s post-processing runs

Map the filter to the forwarded resource with <dispatcher>FORWARD</dispatcher>

A filter does not run on a forward, include or error page (observed)

A mapping without <dispatcher> is REQUEST only

List FORWARD, INCLUDE, ERROR, ASYNC explicitly

Filters run in a surprising order

Annotations give no order; <servlet-name> mappings run after all <url-pattern> mappings (observed)

Declare order-sensitive filters in web.xml

The request is silently blocked

A filter forgot chain.doFilter, or continued after sendError

Return after blocking; call the chain exactly once

getRequestURI() shows the wrong path inside an included page (observed)

Path methods describe the including request

Read the jakarta.servlet.include.* attributes

IllegalStateException from startAsync

A filter or servlet in the path is not async-capable

Set asyncSupported=true on all of them

Redirect lands on the wrong host behind a proxy

Relative or context-relative Location (Tomcat 11 default) behind a path-rewriting proxy

Set useRelativeRedirects on the context, or fix the proxy

Refresh re-posts the form

The response to a POST was a 200 page

Answer with 303 and a GET (Post/Redirect/Get)

Session id in URLs and logs (observed)

URL rewriting is on until the cookie is confirmed

<tracking-mode>COOKIE</tracking-mode>

Session fixation

The session id was not rotated at login (observed rotation)

request.changeSessionId() on login and privilege change

Cookie missing in the next request

Wrong path or domain, Secure over HTTP, SameSite blocking a cross-site request, a size or count limit

Check the Set-Cookie line against the request URL; cookies carry only name and value back

getCookies() is null

The request had no cookie header

Test for null first

Cookie value built from request data

Header injection and tampering

Validate against an allowlist, keep the value opaque

ClassNotFoundException: javax.servlet... on Tomcat 10 and 11, or "Invalid <url-pattern>" at deployment (observed)

javax.* code needs migration, or a pattern mixes wildcards

Rename to jakarta.* or run the Tomcat migration tool; use only exact, prefix, extension, / and "" patterns

404 for a view under WEB-INF (observed)

Container refuses direct access to WEB-INF

Forward to it from a controller; this is the desired behaviour

JSP page shown without data

A browser requested the view directly instead of the controller

Keep views under WEB-INF and map controllers

Error page shows the wrong exception or none (observed)

java.lang.Throwable declared as <exception-type> hides the root-cause pass; the default entry gets exception but not exception_type

Use the default entry as catch-all; read jakarta.servlet.error.exception

Truncated response, curl: (18) (observed)

An exception after the response was committed cannot reach an error page

Do risky work before the first flush

Upload accepts huge files, or a non-multipart POST gives 500 (observed)

Limits left unlimited by default; no content-type check

Set maxFileSize and maxRequestSize, check Content-Type, answer 413 and 415

Files overwritten or written outside the upload folder

The client’s file name was used as a path

Generate the file name on the server; use getSubmittedFileName only for display

NotSerializableException or IllegalArgumentException on session replication

A non-serializable object in a session of a distributable application

Store Serializable values only, and keep sessions small

Memory growth under load

Large objects in sessions and long idle timeouts

Store ids, not graphs; shorten the timeout; invalidate on logout

Thread starvation

Blocking calls in the request thread under high concurrency

Use startAsync for slow remote calls, or measure virtual threads

What Changed Recently

Table 30. Servlet 6.1 (Jakarta EE 11)
Change Detail

Redirect control

sendRedirect overloads with status and clearBuffer; containers may send relative Location headers (issues 74, 275)

Error dispatch

jakarta.servlet.error.query_string, and jakarta.servlet.error.method; error dispatches are always GET (issues 59, 544)

HttpSession.Accessor

getAccessor() lets applications (typically WebSocket) work with a session outside a request (issue 546)

Cookies

Valueless attributes (HttpOnly, Secure, Partitioned) clarified (issue 571)

Parameters

Parameter methods may throw a runtime exception when parsing fails (issue 431); ServletContext path methods carry a security note and canonicalization rules (issue 453)

API additions

Charset overloads of setCharacterEncoding, ByteBuffer support on ServletInputStream/ServletOutputStream, constants 308, 421, 422, 426, secure_protocol request attribute

HTTP/2 server push

Deprecated and optional (issue 538)

Spec hygiene

SecurityManager references removed; TRACE responses drop sensitive headers; HTTPS support clarified; CONNECT requests are rejected by default

Next

Servlet 6.2 is in progress (milestone 6.2.0-M2 on Maven Central); it is part of the Jakarta EE 12 work and is not required for anything on this page

Table 31. Servlet 6.0 (Jakarta EE 10)
Change Detail

Removals

SingleThreadModel, HttpSessionContext, HttpUtils, and the deprecated methods that went with them (for example encodeUrl, encodeRedirectUrl, HttpServletRequest.isRequestedSessionIdFromUrl, ServletContext.getServlet, HttpSession.getValue, putValue)

Cookies

RFC 6265 replaces RFC 2109; generic cookie attributes with setAttribute/getAttribute (so SameSite); setComment and setVersion deprecated for removal

HTTP methods

PATCH supported (doPatch); doHead no longer wraps the response, container-implemented HEAD

Identifiers

getRequestId, getProtocolRequestId, getServletConnection

Descriptor

<cookie-config><attribute> configures extra session-cookie attributes such as SameSite

Platform

Java 11 minimum; module-info; X-Powered-By recommendation dropped

Table 32. Servlet 5.0 and the namespace
Change Detail

javax.servlet to jakarta.servlet

The only change of 5.0 (Jakarta EE 9); a Servlet 4.0 application becomes 5.0 and later by renaming packages (spec Appendix A.3)

Tomcat

10.0 implemented 5.0 and is end of life; 10.1 implements 6.0; 11 implements 6.1. The Tomcat migration tool converts javax WARs automatically

Where the Books Are Outdated

Web Development with JavaServer Pages (Manning, 2001, listed in the section bibliography) is written for the Servlet 2.3 API and Tomcat 4. The concepts it covers on this page survived unchanged: lifecycle, request and response, sessions, filters, listeners and the WAR layout. The details have not.

Table 33. Servlet 2.3-era material and its modern equivalent
The book’s era Today (Servlet 6.1)

javax.servlet.* packages

jakarta.servlet.* since Jakarta EE 9; Tomcat 10 and later do not run the old names

SingleThreadModel to make a servlet safe

Removed in 6.0; make the servlet stateless or thread-safe

HttpUtils.getRequestURL, HttpSessionContext, session.getValue/putValue, response.encodeUrl

Removed in 6.0; use request.getRequestURL(), getAttribute/setAttribute, encodeURL

web.xml with a <!DOCTYPE web-app PUBLIC "-//Sun Microsystems..."> DTD

The web-app_6_1.xsd schema and version="6.1"; most elements are optional because annotations exist

Every servlet, filter and listener declared in web.xml

@WebServlet, @WebFilter, @WebListener, @MultipartConfig; web.xml only for ordering and overrides

<taglib> directly under <web-app>

<jsp-config>, or automatic TLD discovery in JARs

ISO-8859-1 assumed, request.setCharacterEncoding in every servlet

<request-character-encoding> and <response-character-encoding>; UTF-8 everywhere

Cookie attributes limited to those of RFC 2109 (setComment, setVersion)

RFC 6265 and Cookie.setAttribute for SameSite and Partitioned; setComment and setVersion are deprecated for removal

Session id in the URL as a normal fallback

Cookies only; URL rewriting leaks the id

Application-managed login with a session attribute and a role filter

Container-managed or framework authentication; if you keep an application login, call changeSessionId() at login

File upload with a third-party multipart library

@MultipartConfig and Part, built in since Servlet 3.0

No asynchronous processing, no non-blocking I/O

startAsync, ReadListener, WriteListener, and (6.1) ByteBuffer streams

Servlet and listener ordering by tool convention

<absolute-ordering>, <ordering> in fragments, <load-on-startup>

HttpSessionActivationListener and bindings as the only session hooks

Same interfaces, plus HttpSessionIdListener (3.1) and HttpSession.Accessor (6.1)

jsp:plugin and applets for rich clients

Removed from Pages 4.0; applets no longer run in browsers

Tomcat 4 directories and server.xml contexts

Tomcat 11 with webapps/, conf/Catalina/localhost/*.xml and context.xml; CATALINA_BASE separation

References