Custom Tags: Classic Handlers

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 Pages 4.0 (API jakarta.servlet.jsp:jakarta.servlet.jsp-api 4.0.0, the specification of Jakarta EE 11) together with Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 (API 3.0.2, implementation 3.0.1) and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21. Everything was verified on 2026-10-08: the rules against chapter 7 of the Pages 4.0 specification, the Javadoc of the jakarta.servlet.jsp.tagext package and the Tomcat 11.0 Jasper how-to, and every example by building it into a Maven WAR (context path /g6classic) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones that run produced. A classic tag handler is the oldest way to write a custom action in Java: a class that implements the Tag, IterationTag or BodyTag interface, which the page’s generated code instantiates (or takes from a pool), configures through setters and drives through a fixed protocol of doStartTag, doAfterBody and doEndTag calls whose return codes steer what happens next. It is the model that JSP 1.1 introduced in 1999, and it is still fully supported in Pages 4.0, but since JSP 2.0 the simpler SimpleTag API and tag files are the recommended way to write new tags. This page explains the classic model in full (because the JSTL implementation, Struts 1 and a great deal of existing code are classic handlers), shows every part of it on the Bookshelf scenario, and then explains the most dangerous property of the model, the handler pooling that Tomcat applies to it, including the Tomcat vulnerability CVE-2024-52318 that pooling caused. The SimpleTag model (Custom Tags: SimpleTag), the tag library descriptor in depth (Tag Library Descriptors & EL Functions) and tag files (Tag Files & Layouts) each have a page of their own in this section; this page writes the descriptor only as far as a classic handler needs it. The expression language itself is on the expression language basics page, and the Standard Tag Library whose own handlers are classic ones is on the JSTL Core page.

What a Classic Handler Is and When to Use It

A custom action is an element such as <bs:shelf items="${books}">...</bs:shelf> whose prefix is bound by a taglib directive to a tag library, a set of actions described by a tag library descriptor (TLD) and implemented by Java classes. Chapter 7 of the Pages specification defines two ways to implement the Java side: classic tag handlers (section 7.1.3) and simple tag handlers (section 7.1.5); and, since JSP 2.0, a third way that needs no Java at all, the tag file. A classic handler is "a Java class that implements the Tag, IterationTag, or BodyTag interface, and is the run-time representation of a custom action", as the specification puts it. The page implementation class "instantiates a tag handler object, or reuses an existing tag handler object, for each action in the JSP page"; the second half of that sentence is the pooling of the second half of this page.

Table 1. The three ways to implement a custom action (Pages 4.0, chapter 7, checked 2026-10-08)
Model What you write Status in Pages 4.0 Choose it when

Tag file (.tag, .tagx)

JSP syntax in WEB-INF/tags, no Java

current (JSP 2.0)

the tag is mostly markup: layouts, components, formatting

Simple tag handler

a class implementing SimpleTag, usually extending SimpleTagSupport; one method, doTag

current (JSP 2.0)

the tag needs Java but not the classic protocol; this is the default for new code

Classic tag handler

a class implementing Tag, IterationTag or BodyTag, usually extending TagSupport or BodyTagSupport

supported, not deprecated; legacy by recommendation

you maintain existing classic tags, you extend a library built on them (the JSTL ConditionalTagSupport and LoopTagSupport, Struts 1, many third-party libraries), or you need a feature only classic handlers have

What only classic handlers have is a short list: the return-code protocol itself, which lets one handler skip its body, repeat it or end the page; a BodyContent buffer that the handler can read and rewrite; TryCatchFinally hooks (the simple model has the doTag method and an ordinary try block instead); and compatibility with code that expects a Tag, such as the JSTL base classes. Everything else is easier with a SimpleTag: there is no return-code protocol, no pooling hazard (a simple handler is created for every use), no BodyContent, and the body is a JspFragment that can be invoked any number of times. The practical rule is therefore: read and fix classic handlers, extend the JSTL base classes when you need a conditional or loop tag compatible with c:forEach and c:choose, and write everything else as a tag file or a simple handler.

The Bookshelf Tag Library Used on This Page

All the examples below are classes of one WAR, deployed under the context path /g6classic on Tomcat 11.0.26 as g6classic.war. The classes are in the package org.example.bookshelf.tags; imports and the package line are left out of the listings. A ServletContextListener puts the four books of the catalogue into the application attribute books (a record Book(long id, String title, String author, int year): Dune 1965, Neuromancer 1984, The Dispossessed 1974, Cosmos 1980), and the tag library bs is declared in WEB-INF/bs.tld with the URI https://example.org/bookshelf/classic. The Maven dependencies are the same as for the rest of this section: the Jakarta Servlet, Pages and Expression Language APIs with scope provided, jakarta.annotation-api 3.0.0 with scope provided (for the injection example), and the JSTL API 3.0.2 and implementation 3.0.1 (the latter two inside the WAR, because Tomcat does not ship JSTL).

Table 2. The handlers of the example WAR
Handler Base Demonstrates

RecentTag (bs:recent)

TagSupport

a plain conditional tag: SKIP_BODY against EVAL_BODY_INCLUDE

ShelfTag (bs:shelf) with ShelfTagExtraInfo

TagSupport

iteration with doAfterBody and EVAL_BODY_AGAIN; a NESTED scripting variable

PositionTag (bs:position)

TagSupport

a cooperating tag: findAncestorWithClass

ShoutTag (bs:shout, bs:rawshout, bs:plainshout)

BodyTagSupport

EVAL_BODY_BUFFERED, BodyContent, getPreviousOut, the body-content values

GuardTag, BoomTag (bs:guard, bs:boom)

TagSupport + TryCatchFinally

doCatch and doFinally

CountTag (bs:count)

TagSupport

an AT_END scripting variable declared in the TLD

OldBooksTag, EachTag (bs:oldBook, bs:each)

ConditionalTagSupport, LoopTagSupport

the JSTL base classes

StaleTag, FreshTag (bs:stale, bs:fresh)

TagSupport

pooling: state that leaks, and state that does not

AuditTag, IdTag (bs:audit, bs:id)

TagSupport

injection callbacks, JspIdConsumer, and which handlers are pooled

The Interfaces and the Support Classes

Three interfaces build on each other, and two classes implement them so that a handler overrides only what it needs. The specification states the division of labour: Tag has the setters that initialise the handler and the doStartTag and doEndTag methods; IterationTag adds doAfterBody, which is called to decide whether to evaluate the body again; and BodyTag adds setBodyContent and doInitBody for a handler that wants the body in a buffer. TryCatchFinally is a "mix-in" that can be added to any of the three.

Table 3. The classic API in jakarta.servlet.jsp.tagext (Pages 4.0 Javadoc, checked 2026-10-08)
Type Methods and constants Role

Tag

setPageContext, setParent, getParent, doStartTag, doEndTag, release; SKIP_BODY, EVAL_BODY_INCLUDE, SKIP_PAGE, EVAL_PAGE

the basic protocol; setters first, then doStartTag, the body, doEndTag

IterationTag extends Tag

doAfterBody; EVAL_BODY_AGAIN

repeat the body any number of times

BodyTag extends IterationTag

setBodyContent, doInitBody; EVAL_BODY_BUFFERED

receive the evaluated body in a BodyContent buffer

TryCatchFinally

doCatch(Throwable), doFinally()

guarantee cleanup when the body or a method throws

TagSupport implements IterationTag

default methods; id and pageContext fields; setId/getId, setValue/getValue, static findAncestorWithClass

the usual base class; doStartTag returns SKIP_BODY, doEndTag returns EVAL_PAGE, doAfterBody returns SKIP_BODY

BodyTagSupport extends TagSupport implements BodyTag

bodyContent field, getBodyContent, getPreviousOut; default doStartTag returns EVAL_BODY_BUFFERED

the usual base class for a handler that processes its body

BodyContent extends JspWriter

getString, getReader, writeOut, clearBody, getEnclosingWriter

the buffer that holds the body of a BodyTag

TagExtraInfo, TagData, VariableInfo

getVariableInfo, isValid, validate; NESTED, AT_BEGIN, AT_END

translation-time information: scripting variables and attribute validation

JspIdConsumer

setJspId

the container passes a page-unique id to the handler

The Lifecycle and the Return Codes

For every use of a custom action the generated code does the same things in the same order: get a handler instance, call setPageContext and setParent, call one setter for every attribute that the page supplied, then call doStartTag. What that returns decides whether and how the body runs. After the body, doEndTag runs, and what that returns decides whether the rest of the page runs. A handler that implements TryCatchFinally is protected by a try block around all of this. When the use ends, the handler goes back to a pool (or is discarded); release() is not called between two uses.

flowchart TD get["Get a handler: new instance or from the pool"] --> set["setPageContext, setParent,
one setter per attribute"] set --> start["doStartTag()"] start -->|"SKIP_BODY"| endt start -->|"EVAL_BODY_INCLUDE"| body["Evaluate the body
into the current out"] start -->|"EVAL_BODY_BUFFERED
(BodyTag only)"| sbc["setBodyContent(buffer)
pushBody"] sbc --> init["doInitBody()"] init --> body2["Evaluate the body
into the BodyContent buffer"] body --> after["doAfterBody()"] body2 --> after after -->|"EVAL_BODY_AGAIN"| again{"Buffered?"} again -->|"no"| body again -->|"yes"| body2 after -->|"SKIP_BODY"| pop["popBody (buffered case)"] pop --> endt["doEndTag()"] endt -->|"EVAL_PAGE"| rest["The rest of the page is evaluated"] endt -->|"SKIP_PAGE"| stop["The rest of the page is skipped:
request completes"] rest --> reuse["Handler goes back to the pool
or is destroyed"] reuse -.->|"only when the pool or page is discarded"| rel["release()"] start -.->|"exception in any step"| tcf["doCatch(t), then doFinally()
(TryCatchFinally handlers)"]
Figure 1. The classic protocol: setters, the body loop and the end tag

Four sets of constants steer the protocol, and each is legal only from certain methods.

Table 4. The return codes and where they are allowed (Pages 4.0 Javadoc, checked 2026-10-08)
Constant Declared in Meaning and rules

SKIP_BODY

Tag

from doStartTag: the body is not evaluated, then doEndTag; from doAfterBody: stop repeating. If the TLD declares <body-content>empty</body-content>, doStartTag must return it

EVAL_BODY_INCLUDE

Tag

from doStartTag: evaluate the body and write it straight to the current out; valid for every handler, but a handler that implements only Tag cannot see the body

EVAL_BODY_BUFFERED

BodyTag

from doStartTag of a BodyTag: evaluate the body into a new BodyContent, after setBodyContent and doInitBody; it is an error from a handler that does not implement BodyTag, and it is only used if the action has a body

EVAL_BODY_AGAIN

IterationTag

from doAfterBody: evaluate the body again, then call doAfterBody again

EVAL_PAGE

Tag

from doEndTag: continue with the rest of the page

SKIP_PAGE

Tag

from doEndTag: do not evaluate the rest of the page; the request completes. In a page that was reached by an include, the including page continues; the doEndTag of enclosing tags is not called

The protocol is not abstract: it is the Java that Jasper writes into the page’s servlet. The listing is the loop that Tomcat 11.0.26 generated for the iteration tag of this page (bs:shelf, shown in full below), trimmed to the calls that matter, with the generated names shortened; it was read from the work directory of the web application after the first request.

What Jasper generates for an IterationTag (trimmed)
ShelfTag th = (ShelfTag) _jspx_tagPool_bs_shelf_items_id.get(ShelfTag.class);
boolean reused = false;
try {
  th.setPageContext(_jspx_page_context);
  th.setParent(null);
  th.setId("book");
  th.setItems((java.util.List) PageContextImpl.proprietaryEvaluate("${books}", java.util.List.class, ...));
  int eval = th.doStartTag();
  if (eval != Tag.SKIP_BODY) {
    org.example.bookshelf.Book book = null;
    book = (org.example.bookshelf.Book) _jspx_page_context.findAttribute("book");   // NESTED variable
    do {
      // ... the body is written here ...
      int evalDoAfterBody = th.doAfterBody();
      book = (org.example.bookshelf.Book) _jspx_page_context.findAttribute("book");
      if (evalDoAfterBody != BodyTag.EVAL_BODY_AGAIN) break;
    } while (true);
  }
  if (th.doEndTag() == Tag.SKIP_PAGE) { return; }
  _jspx_tagPool_bs_shelf_items_id.reuse(th);
  reused = true;
} finally {
  JspRuntimeLibrary.releaseTag(th, _jsp_getInstanceManager(), reused);
}

Three things in the listing explain most classic-tag behaviour. The pool is keyed by the tag and the set of attributes used (the field name carries shelf, items and id), so two uses of the same tag with different attributes never share an instance. The body is a do ... while loop that doAfterBody controls. And the handler is returned to the pool (reuse) only on the normal path; if an exception escapes, releaseTag is called with reused false and the handler is thrown away (after release() and, if the container manages instances, destruction).

For a BodyTag that returns EVAL_BODY_BUFFERED, the generated code additionally brackets the loop with JspRuntimeLibrary.startBufferedBody (which pushes a BodyContent onto the page context) and popBody; the handler sees the buffer through setBodyContent, doInitBody and getBodyContent.

A Plain and a Conditional Tag

The simplest handler overrides doStartTag only. RecentTag decides, from two attributes, whether its body is evaluated; this is the "conditional" of the specification, done with the return value of doStartTag and nothing else. Attributes arrive through setters whose names follow the JavaBeans rule, one per <attribute> in the TLD, and the handler resets its state in release().

A conditional classic tag
public class RecentTag extends TagSupport {
    private Book book;
    private int since = 1980;

    public void setBook(Book book) { this.book = book; }
    public void setSince(int since) { this.since = since; }

    @Override
    public int doStartTag() throws JspException {
        return book.year() >= since ? EVAL_BODY_INCLUDE : SKIP_BODY;
    }

    @Override
    public void release() {
        book = null;
        since = 1980;
        super.release();
    }
}

The TLD entry is what makes the class a tag. The element names name, tag-class, body-content and attribute (with name, required, rtexprvalue and type) are the ones of the Pages 4.0 TLD schema, web-jsptaglibrary_4_0.xsd; rtexprvalue set to true means that the attribute accepts a ${...} expression and other request-time values. The full schema, the location rules for descriptors and how Tomcat finds them are the subject of the tag library descriptor page of this section.

The TLD entry of bs:recent, inside the taglib element of WEB-INF/bs.tld
<tag>
    <name>recent</name>
    <tag-class>org.example.bookshelf.tags.RecentTag</tag-class>
    <body-content>JSP</body-content>
    <attribute>
        <name>book</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>org.example.bookshelf.Book</type>
    </attribute>
    <attribute>
        <name>since</name>
        <rtexprvalue>true</rtexprvalue>
        <type>int</type>
    </attribute>
</tag>
Using it: the books published in 1980 or later get a label
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/classic" %>
<ul>
<c:forEach var="b" items="${applicationScope.books}">
  <li>${b.title}<bs:recent book="${b}" since="1980"> (recent)</bs:recent></li>
</c:forEach>
</ul>
Output of curl http://localhost:18080/g6classic/index.jsp (blank lines removed)
<ul>
  <li>Dune</li>
  <li>Neuromancer (recent)</li>
  <li>The Dispossessed</li>
  <li>Cosmos (recent)</li>
</ul>

A handler that implements only Tag and returns EVAL_BODY_INCLUDE cannot post-process the body; it can only let the body through or suppress it. That is exactly what c:if does, and it is why the JSTL ships a base class for it (see below).

Iteration and Scripting Variables

An IterationTag repeats its body: doStartTag prepares the first item and returns EVAL_BODY_INCLUDE (or SKIP_BODY for an empty collection), and every doAfterBody either prepares the next item and returns EVAL_BODY_AGAIN or returns SKIP_BODY to leave the loop. The item is handed to the body by storing it in a scope of the page context, which is what a classic handler uses for every result: pageContext.setAttribute(name, value). ShelfTag uses the id attribute that TagSupport already provides as the variable name, and removes the variable in doEndTag, so that it does not outlive the tag.

An iteration tag that exports one book per pass
public class ShelfTag extends TagSupport {
    private List<Book> items;
    private Iterator<Book> iterator;
    private int position;

    public void setItems(List<Book> items) { this.items = items; }
    public int getPosition() { return position; }

    @Override
    public int doStartTag() throws JspException {
        iterator = items.iterator();
        position = 0;
        return export() ? EVAL_BODY_INCLUDE : SKIP_BODY;
    }

    @Override
    public int doAfterBody() throws JspException {
        return export() ? EVAL_BODY_AGAIN : SKIP_BODY;
    }

    @Override
    public int doEndTag() throws JspException {
        pageContext.removeAttribute(getId());
        return EVAL_PAGE;
    }

    private boolean export() {
        if (!iterator.hasNext()) {
            return false;
        }
        pageContext.setAttribute(getId(), iterator.next());
        position++;
        return true;
    }

    @Override
    public void release() {
        items = null;
        iterator = null;
        position = 0;
        super.release();
    }
}

Because the item is a scoped attribute, the body can read it with EL (${book.title}) without any further declaration. A scripting variable is something more: a Java local variable that the translator declares in the page so that a scriptlet or an expression can use it as book. The page must be told about it at translation time, and there are two ways, which the specification describes as "directly in the TLD for simple cases, or through subclasses of TagExtraInfo`". Whichever way declares it, the handler still stores the object with `pageContext.setAttribute; the translator generates the code that copies the attribute into the local variable ("synchronisation") at the right moments. The moments are the scope of the variable.

Table 5. The scope of a scripting variable (VariableInfo, Pages 4.0 Javadoc)
Scope Synchronised Typical use

NESTED

between the start tag and the end tag: before the body and after every doAfterBody; not after the end tag

the current item of a loop

AT_BEGIN

from the start tag to the end of the enclosing scope: after doStartTag, after every doAfterBody and after doEndTag

a value created at the start that also belongs after the tag

AT_END

after the end tag only

a result that exists once the tag has finished

ShelfTagExtraInfo declares the variable named by the id attribute, of type org.example.bookshelf.Book, with declaration (true, a new Java variable) and NESTED scope. It is wired in with <tei-class> in the TLD entry of bs:shelf. The TagData argument carries the attribute values known at translation time; getId() is the id attribute, which is why id is declared with rtexprvalue false.

The TagExtraInfo and the TLD entry of bs:shelf
public class ShelfTagExtraInfo extends TagExtraInfo {
    @Override
    public VariableInfo[] getVariableInfo(TagData data) {
        return new VariableInfo[] {
            new VariableInfo(data.getId(), "org.example.bookshelf.Book", true, VariableInfo.NESTED)
        };
    }
}
<tag>
    <name>shelf</name>
    <tag-class>org.example.bookshelf.tags.ShelfTag</tag-class>
    <tei-class>org.example.bookshelf.tags.ShelfTagExtraInfo</tei-class>
    <body-content>JSP</body-content>
    <attribute>
        <name>id</name>
        <required>true</required>
        <rtexprvalue>false</rtexprvalue>
    </attribute>
    <attribute>
        <name>items</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>java.util.List</type>
    </attribute>
</tag>

A TagExtraInfo can do more than declare variables: its isValid and validate methods are called at translation time to check the attributes of every use of the tag, which is how a library reports "attribute a and b are mutually exclusive" as a translation error instead of a request-time failure (specification section 7.1.2, "Validation"). The variable element of the TLD is the alternative for the simple cases, shown below with bs:count.

Using the iteration tag, with the EL variable and the scripting variable side by side
<ol>
<bs:shelf id="book" items="${applicationScope.books}">
  <li>#<bs:position/> ${book.title} / <%= book.author() %></li>
</bs:shelf>
</ol>
after: ${empty pageScope.book ? 'book removed' : 'book still there'}
Output
<ol>
  <li>#1 Dune / Frank Herbert</li>
  <li>#2 Neuromancer / William Gibson</li>
  <li>#3 The Dispossessed / Ursula K. Le Guin</li>
  <li>#4 Cosmos / Carl Sagan</li>
</ol>
after: book removed

The scriptlet <%= book.author() %> compiles because the translator declared book as a Book; that is the only place on this page where a scriptlet appears, because it is the only way to see a scripting variable. In a body that the TLD declares scriptless, or on a page whose property group sets scripting-invalid, scriptlets are not allowed and the container "need not synchronize" the variables; the EL reads the page-context attribute directly and always works.

A Scripting Variable Declared in the TLD

For a variable whose name is a literal or comes from an attribute, no TagExtraInfo class is needed: the TLD variable element declares it with name-given or name-from-attribute, variable-class, declare and scope. CountTag exports the size of a list under the name given in var, with AT_END scope, so the variable exists after the tag.

A handler and its variable element
public class CountTag extends TagSupport {
    private List<?> items;
    private String var;

    public void setItems(List<?> items) { this.items = items; }
    public void setVar(String var) { this.var = var; }

    @Override
    public int doEndTag() throws JspException {
        pageContext.setAttribute(var, items.size());
        return EVAL_PAGE;
    }

    @Override
    public void release() {
        items = null;
        var = null;
        super.release();
    }
}
<tag>
    <name>count</name>
    <tag-class>org.example.bookshelf.tags.CountTag</tag-class>
    <body-content>empty</body-content>
    <variable>
        <name-from-attribute>var</name-from-attribute>
        <variable-class>java.lang.Integer</variable-class>
        <declare>true</declare>
        <scope>AT_END</scope>
    </variable>
    <attribute>
        <name>items</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>java.util.List</type>
    </attribute>
    <attribute>
        <name>var</name>
        <required>true</required>
        <rtexprvalue>false</rtexprvalue>
    </attribute>
</tag>
Using it from EL and from a scriptlet; output total = 4; as scriptlet: 4
<bs:count items="${applicationScope.books}" var="total"/>
<p>total = ${total}; as scriptlet: <%= total %></p>

Cooperating Tags

Two actions can cooperate "explicitly, via scoped variables" (one creates an object under a name and the other reads it by that name) or "implicitly", through their nesting. For the second form the container gives every handler its parent through setParent, and TagSupport.findAncestorWithClass(from, class) walks up the chain and returns the nearest ancestor that is an instance of the class or implements the interface. PositionTag finds the enclosing ShelfTag and prints its position; used outside a shelf it reports the mistake with a JspTagException, which is the standard way to say "this tag is in the wrong place" (a translation-time check in a TagExtraInfo or a validator is better when it is possible, because the author sees the error before any request).

A tag that needs its parent
public class PositionTag extends TagSupport {
    @Override
    public int doStartTag() throws JspException {
        ShelfTag shelf = (ShelfTag) findAncestorWithClass(this, ShelfTag.class);
        if (shelf == null) {
            throw new JspTagException("bs:position must be nested in bs:shelf");
        }
        try {
            pageContext.getOut().print(shelf.getPosition());
        } catch (IOException e) {
            throw new JspTagException(e);
        }
        return SKIP_BODY;
    }
}
Request of a page that uses <bs:position/> outside a shelf (HTTP 500; the root cause shown by Tomcat 11.0.26)
jakarta.servlet.jsp.JspTagException: bs:position must be nested in bs:shelf
	org.example.bookshelf.tags.PositionTag.doStartTag(PositionTag.java:14)
	org.apache.jsp.orphan_jsp._jspx_meth_bs_005fposition_005f0(orphan_jsp.java:162)

The parent of a handler is the nearest enclosing custom action, whichever library it comes from, so a tag nested in c:if inside bs:shelf still finds the shelf even though the c:if handler is in between. A page of <bs:shelf ...><c:if test="${book.year gt 1970}">[<bs:position/> ${book.title}]</c:if></bs:shelf> printed [2 Neuromancer][3 The Dispossessed][4 Cosmos] on Tomcat 11.0.26: the position is that of the shelf, not of the c:if.

Body Tags: BodyTagSupport and BodyContent

A handler that wants to see, change or discard its body returns EVAL_BODY_BUFFERED from doStartTag (that is the default of BodyTagSupport). The container then calls setBodyContent with a new BodyContent, a JspWriter that collects everything the body writes, calls doInitBody (the place to prepare the first pass), evaluates the body into the buffer, and calls doAfterBody. In doAfterBody the handler reads the text with getBodyContent().getString() (or getReader()), and writes the result to the writer that was current before the buffer was pushed. That writer is getPreviousOut() and it is the single most common source of errors for beginners: writing to pageContext.getOut() inside a buffered doAfterBody writes into the very buffer that is about to be discarded, so the output is lost.

A body tag that upper-cases its body
public class ShoutTag extends BodyTagSupport {
    @Override
    public int doStartTag() {
        return EVAL_BODY_BUFFERED;
    }

    @Override
    public int doAfterBody() throws JspException {
        try {
            String text = getBodyContent().getString();
            getPreviousOut().print(text.toUpperCase(Locale.ROOT));
        } catch (IOException e) {
            throw new JspTagException(e);
        }
        return SKIP_BODY;
    }
}

What the body is depends on the body-content element of the TLD, which also decides what the translator accepts. The same ShoutTag class is declared three times in the example TLD (bs:shout with JSP, bs:rawshout with tagdependent, bs:plainshout with scriptless) to show the difference.

Table 6. The body-content values (Pages 4.0 TLD schema; behaviour observed on Tomcat 11.0.26)
Value What the body may contain What the handler receives

empty

nothing; a body is a translation error: According to TLD, tag [bs:boom] must be empty, but is not (observed)

nothing; doStartTag must return SKIP_BODY

JSP

template text, EL, standard and custom actions, scripting elements

the evaluated output, as text

scriptless

template text, EL, standard and custom actions, but no scripting: Scripting elements ( <%!, <jsp:declaration, <%=, <jsp:expression, <%, <jsp:scriptlet ) are disallowed here. (observed)

the evaluated output, as text; the usual value for simple tags and tag files

tagdependent

anything, but the body is passed through unprocessed: EL, nested actions and scripting are not evaluated

the raw text, exactly as written

The three declarations applied to the same body (b holds Dune)
<c:set var="b" value="${applicationScope.books[0]}"/>
jsp:         <bs:shout>${b.title} <i>x</i></bs:shout>
tagdependent:<bs:rawshout>${b.title} <i>x</i></bs:rawshout>
scriptless:  <bs:plainshout>${b.title}</bs:plainshout>
Output
jsp:         DUNE <I>X</I>
tagdependent:${B.TITLE} <I>X</I>
scriptless:  DUNE

The tagdependent line is the reason that value exists: a tag that embeds another language in its body (a code sample, a SQL fragment, a JavaScript template) must not have its text interpreted as EL. Note also what the example shows about security: the handler transformed markup that was already part of the page, so nothing it does makes the text safe or unsafe; a body tag that echoes request data must escape it (see Security Notes below).

TryCatchFinally: Cleaning Up Reliably

A handler that opens something in doStartTag (a lock, a transaction, a counter, a temporary file) cannot rely on doEndTag to close it, because an exception thrown by the body or by a nested tag skips doEndTag. The specification added TryCatchFinally in JSP 1.2 for exactly this, and defines the call pattern: doCatch(t) is called if an exception is thrown from doStartTag, from the body, from doAfterBody or from doEndTag (not from the setters), and doFinally() is always called afterwards, on both paths. If doCatch returns normally the exception is swallowed; if it throws, that is what propagates. release() and doFinally() are not the same thing: doFinally runs after every use and is where per-use state is cleaned up.

A guard that counts open scopes and always closes them
public class GuardTag extends TagSupport implements TryCatchFinally {
    static final String KEY = "openGuards";

    private AtomicInteger counter() {
        AtomicInteger c = (AtomicInteger) pageContext.getServletContext().getAttribute(KEY);
        if (c == null) {
            c = new AtomicInteger();
            pageContext.getServletContext().setAttribute(KEY, c);
        }
        return c;
    }

    @Override
    public int doStartTag() throws JspException {
        counter().incrementAndGet();
        return EVAL_BODY_INCLUDE;
    }

    @Override
    public void doCatch(Throwable t) throws Throwable {
        pageContext.getServletContext().log("guard saw " + t.getClass().getSimpleName());
        throw t;
    }

    @Override
    public void doFinally() {
        counter().decrementAndGet();
    }
}

BoomTag throws JspTagException("the shelf collapsed") from doStartTag. The page wraps the guard in c:catch, which is itself a TryCatchFinally handler, so the exception is caught by the page and the request completes; the counter shows that the guard closed.

A guard around a failing tag, caught by c:catch
<c:catch var="ex">
  <bs:guard>inside<bs:boom/>never printed</bs:guard>
</c:catch>
caught: ${ex.message}<br/>
open guards: ${applicationScope.openGuards}
Output
  inside
caught: the shelf collapsed<br/>
open guards: 0

The text inside was written before the nested tag failed, which shows that an exception does not undo output that reached the buffer; doCatch logged the exception and rethrew it (the ServletContext log line is guard saw JspTagException), and open guards: 0 shows that doFinally ran. Without TryCatchFinally the counter would stay at one for ever, and every failed request would leak a guard.

The JSTL Base Classes

The Standard Tag Library’s own conditional and loop tags are classic handlers, and it publishes the base classes they are built on in jakarta.servlet.jsp.jstl.core, so that a library author gets the same behaviour and the same attributes without re-implementing them.

Table 7. The base classes for conditional and loop tags (Jakarta Tags 3.0 API, checked 2026-10-08)
Type What it provides What the subclass writes

ConditionalTagSupport

setVar and setScope; doStartTag calls condition(), stores the Boolean in the scoped variable if var is given, and returns EVAL_BODY_INCLUDE for true and SKIP_BODY for false

the abstract condition(); setters for its own attributes

LoopTagSupport

setVar and setVarStatus; the begin, end and step fields with validateBegin, validateEnd and validateStep; the iteration control in doStartTag and doAfterBody; it implements LoopTag and TryCatchFinally

the abstract prepare(), hasNext() and next(); public setters for begin, end and step that call the validators

LoopTag

getCurrent() and getLoopStatus()

nothing: a tag that cooperates with any loop looks for it with findAncestorWithClass

LoopTagStatus

getIndex(), getCount(), isFirst(), isLast(), getBegin(), getEnd(), getStep(), getCurrent()

nothing: it is what varStatus holds

OldBooksTag is a conditional tag that stores its result for a c:if or a c:when to use, and EachTag is a loop that iterates over a list of books with the full c:forEach attribute set. The base class does the arithmetic of begin, end and step and the bookkeeping of varStatus; the subclass supplies the three iteration methods and the setters for the three numeric attributes (the base class declares the fields, not the setters, because only the subclass knows which attributes its TLD offers).

Two tags on the JSTL base classes
public class OldBooksTag extends ConditionalTagSupport {
    private Book book;

    public void setBook(Book book) { this.book = book; }

    @Override
    protected boolean condition() {
        return book.year() < 1980;
    }

    @Override
    public void release() {
        book = null;
        super.release();
    }
}

public class EachTag extends LoopTagSupport {
    private List<Book> items;
    private Iterator<Book> iterator;

    public void setItems(List<Book> items) { this.items = items; }

    public void setBegin(int begin) throws JspTagException {
        this.beginSpecified = true;
        this.begin = begin;
        validateBegin();
    }

    public void setEnd(int end) throws JspTagException {
        this.endSpecified = true;
        this.end = end;
        validateEnd();
    }

    public void setStep(int step) throws JspTagException {
        this.stepSpecified = true;
        this.step = step;
        validateStep();
    }

    @Override
    protected void prepare() throws JspTagException { iterator = items.iterator(); }

    @Override
    protected boolean hasNext() throws JspTagException { return iterator.hasNext(); }

    @Override
    protected Object next() throws JspTagException { return iterator.next(); }

    @Override
    public void release() {
        items = null;
        iterator = null;
        super.release();
    }
}
Using both: var on the conditional, then c:if; varStatus, begin and step on the loop
<c:forEach var="b" items="${applicationScope.books}">
  <bs:oldBook book="${b}" var="old"/>
  <c:if test="${old}">${b.title} is older than 1980<br/></c:if>
</c:forEach>
<bs:each items="${applicationScope.books}" var="b" varStatus="st" begin="1" step="2">
  [${st.index}/${st.count}] ${b.title} first=${st.first} last=${st.last}<br/>
</bs:each>
Output (blank lines removed)
  Dune is older than 1980<br/>
  The Dispossessed is older than 1980<br/>
  [1/1] Neuromancer first=true last=false<br/>
  [3/2] Cosmos first=false last=true<br/>

The loop output shows the two numbers that confuse every first-time user of varStatus: index is the position in the underlying collection (1 and 3 with begin="1" step="2") and count is the number of the pass (1 and 2). The iteration page of this section covers the same two properties for c:forEach. A tag that must run inside any loop and ask "which pass is this?" implements nothing; it calls findAncestorWithClass(this, LoopTag.class) and reads getLoopStatus(), and then works with c:forEach, c:forTokens and bs:each alike.

Tag Handler Pooling

Creating a handler object for every use of every tag, many times per request, is wasteful, and the specification therefore allows the page to reuse handlers rather than create new ones. Jasper, the JSP engine of Tomcat, does the latter: every page servlet owns a TagHandlerPool for each distinct combination of tag and attribute names it uses (visible in the generated source as a field whose name Jasper mangles from _jspx_tagPool_bs_shelf_items_id); get takes a handler from the pool or creates one, and reuse puts it back after a normal doEndTag. When the page is unloaded, _jspDestroy releases the pools, and that is when release() is called on every pooled handler. The Tomcat Jasper how-to lists the feature as "JSP Custom Tag Pooling", describes the switch as enablePooling ("Determines whether tag handler pooling is enabled. This is a compilation option. It will not alter the behaviour of JSPs that have already been compiled", true or false, default true), and the poolTagsWithExtends option for pages that use their own base class (both are listed with the other options on Jasper Configuration & Precompilation). The consequence is the one rule of classic handlers that everything else follows from.

A pooled handler is used again by the same page, by later requests and by other threads (never two at once), without release() in between. The Tag Javadoc says it in its lifecycle notes: setters "may be called again before a tag handler is reused", attribute setters "are called if the values differ or are expressed as request-time attribute values", and release() is for "releasing long-term data". Anything a handler stores in a field and does not reset is therefore seen by the next use. The two handlers below differ only in that respect; both print how many times they have been called since their state was last reset, and both are used four times in a loop.

A handler whose state leaks, and one that does not
public class StaleTag extends TagSupport {     // BROKEN on purpose
    private int seen;

    @Override
    public int doStartTag() throws JspException {
        seen++;
        print(seen);
        return SKIP_BODY;
    }
}

public class FreshTag extends TagSupport {
    private int seen;

    @Override
    public int doStartTag() throws JspException {
        seen++;
        print(seen);
        return SKIP_BODY;
    }

    @Override
    public int doEndTag() {
        seen = 0;                               // per-use state is cleared after every use
        return EVAL_PAGE;
    }

    @Override
    public void release() {
        seen = 0;
        super.release();
    }
}

(print stands for pageContext.getOut().print(...) wrapped in a try block that turns an IOException into a JspTagException.)

The page, and its output with pooling on (the default) and with enablePooling set to false (observed)
<p>stale: <c:forEach begin="1" end="4"><bs:stale/> </c:forEach></p>
<p>fresh: <c:forEach begin="1" end="4"><bs:fresh/> </c:forEach></p>
Pooling bs:stale bs:fresh

on (default)

1 2 3 4

1 1 1 1

off (enablePooling false)

1 1 1 1

1 1 1 1

StaleTag is a bug that depends on a performance feature: the code is "correct" with pooling off and fails with it on, and the failure depends on how handlers are recycled, which is a property of the container and of load. With pooling off the same page prints four times 1, which is what an author who tested on a single request would have seen. The rule that follows is simple and absolute: everything that is set per use must be set in doStartTag or by a setter, and everything that accumulates per use must be reset in doEndTag or in doFinally`; `release() is a place for long-term state only. An optional attribute that a page does not give is not set by the container at all: its setter is not called. Jasper keeps one pool per tag and set of attribute names, so on Tomcat a use without since never receives a handler that had since set; the specification does not promise that, and another container may reuse the instance, so a handler keeps its defaults in field initialisers and puts them back after every use, in doEndTag or doFinally.

The pool is how to disable the problem, and the switch is a servlet initialisation parameter of Jasper’s own servlet. It applies when pages are compiled, so existing compiled pages are not affected until they are recompiled.

Switching tag pooling off for a whole web application (WEB-INF/web.xml)
<servlet>
    <servlet-name>jsp</servlet-name>
    <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
    <init-param>
        <param-name>enablePooling</param-name>
        <param-value>false</param-value>
    </init-param>
    <load-on-startup>3</load-on-startup>
</servlet>

Turning pooling off is a diagnostic, not a fix: it hides handlers that keep state and it costs allocation on every use, and it is not available to code that must run on other containers. Jasper also disables pooling by itself for some tags. A page that declares its own base class with the extends attribute of the page directive is not pooled unless poolTagsWithExtends is true (the how-to explains that Jasper cannot guarantee that the base class has done the initialisation that the pools need), and, as the next section shows, a handler that implements JspIdConsumer is created anew for every use.

Pooling and the Tomcat Vulnerability CVE-2024-52318

Pooling is not only a correctness risk; it has been a security one in Tomcat itself. CVE-2024-52318, titled "Incorrect JSP tag recycling leads to XSS" in the CVE record and "XSS in generated JSPs" in the Tomcat security pages, was rated Important. The Tomcat advisory states the cause: the fix for an earlier improvement (Bugzilla 69333) stopped pooled JSP tags from being released after use, which could make the output of some tags unescaped and so lead to cross-site scripting. Only a handful of releases were affected, and the CVE record lists them: Tomcat 11.0.0, 10.1.31 and 9.0.96. The fix is in 11.0.1, 10.1.33 (10.1.32 was prepared, but its release vote failed, so Tomcat’s pages say to download 10.1.33) and 9.0.97, all published in November 2024; the application in this page runs on 11.0.26, which contains the fix. The lesson for a handler author is the same as the one in the previous section seen from the other side: the output of a tag must not depend on whether the handler is fresh or recycled, because the container, not the author, decides when that is, and a bug in the container’s recycling can turn a stale field into an injection. The lesson for an operator is to keep Tomcat on a current patch release of its line.

Table 8. Tomcat releases involved in CVE-2024-52318 (Tomcat security pages and the CVE record, verified 2026-10-08)
Line Affected Fixed in

11.0.x

11.0.0

11.0.1

10.1.x

10.1.31

10.1.33 (the CVE record says 10.1.32; the Tomcat page explains that 10.1.32 was not released)

9.0.x

9.0.96

9.0.97

Container Services for Handlers: Injection and the JSP Id

Chapter 7 of the specification gives classic handlers three services beyond the protocol. First, resource injection (section 7.1.11): handlers that implement Tag or SimpleTag "may be annotated for injection", which happens "immediately after an instance of the tag handler is constructed, and before any of the tag properties are initialized". The supported annotations are @EJB, @PersistenceContext, @PersistenceUnit, @PostConstruct, @PreDestroy, @Resource and @WebServiceRef (and their plural forms), and a container that is not part of a Jakarta EE implementation is "encouraged, but not required" to support them; resource injection is not supported for JSP pages or tag files. Second, a handler that implements JspIdConsumer receives, through setJspId, an identification of its use in the page that is unique within the translation (section 7.1.10). Third, tag libraries can declare event listeners in the TLD, which the container registers before the application starts (section 7.1.9); that is the subject of the descriptor page of this section.

AuditTag counts the construction and destruction callbacks of its instances, and IdTag implements JspIdConsumer. Each is used in a loop; the page was requested twice, and the counters are static, so they accumulate over the lifetime of the web application.

One handler that counts its own life cycle and one that asks for its id
public class AuditTag extends TagSupport {
    private static final AtomicInteger CREATED = new AtomicInteger();
    private static final AtomicInteger DESTROYED = new AtomicInteger();

    @PostConstruct
    void created() { CREATED.incrementAndGet(); }

    @PreDestroy
    void destroyed() { DESTROYED.incrementAndGet(); }

    @Override
    public int doStartTag() throws JspException {
        print("created=" + CREATED + " destroyed=" + DESTROYED + "<br/>");
        return SKIP_BODY;
    }
}

public class IdTag extends TagSupport implements JspIdConsumer {
    private String jspId;

    @Override
    public void setJspId(String id) { this.jspId = id; }

    @Override
    public int doStartTag() throws JspException {
        print("jspId=" + jspId + "<br/>");
        return SKIP_BODY;
    }
}
The page, and its output on both requests (blank lines removed)
<c:forEach begin="1" end="3"><bs:audit/></c:forEach>
<c:forEach begin="1" end="2"><bs:id/></c:forEach>
created=1 destroyed=0<br/>created=1 destroyed=0<br/>created=1 destroyed=0<br/>
jspId=jsp_652656546_0<br/>jspId=jsp_652656546_0<br/>

The first line is the proof that pooling works and that injection callbacks run once per instance and not once per use: three uses in a loop, and two requests, produced one @PostConstruct call and no @PreDestroy. The generated source for bs:id is different: Jasper writes new IdTag(), then _jsp_getInstanceManager().newInstance(...) and setJspId("jsp_652656546_0"), for every use and never puts the handler in a pool. That behaviour is not stated in the specification or the Jasper how-to; it is what Tomcat 11.0.26 does, and it is a reason not to depend on JspIdConsumer in a hot path.

Security Notes

A classic handler writes to the response, so the rules of any output code apply to it unchanged.

  • Escape what is not yours. A handler that prints an attribute value, a request parameter or a property of a model object into HTML must escape it (with a small escaping method of your own, since a handler cannot call c:out); c:out escapes by default and is the reason to prefer it in the page over a bare expression. ShoutTag above is safe only because its input is template text: a body tag that wraps text of a user in markup must escape that text first.

  • Keep handlers free of per-user state in fields. A pooled handler is shared between users in sequence; a field that holds the name of the current user and is not cleared is a data leak (and the pooling section showed how a stale field becomes an XSS in the case of CVE-2024-52318).

  • Do not build expressions from request input. A tag attribute that is evaluated as EL by the handler (the old way of supporting EL in a tag before JSP 2.0) is an expression injection if its text can come from a request; since JSP 2.0 the container evaluates attributes, and the handler receives the value.

  • Do not extend scripting. The scripting variables of a classic tag exist for the scriptlets that are discouraged on the scripting elements page; new tags need not declare any, and a library used in scripting-free pages (scripting-invalid in the JSP property groups) loses nothing.

  • Keep the container patched. CVE-2024-52318 shows that the pooling machinery itself can be the vulnerability.

Pitfalls and Error Names

Table 9. Classic-handler pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

a counter or a flag keeps its value from the previous use: 1 2 3 4 instead of 1 1 1 1 (observed)

a field is not reset and the handler is pooled

reset per-use state in doEndTag or doFinally; keep release() for long-term data

an optional attribute seems to keep the value of an earlier use (on a container that shares pools between attribute sets)

the setter is not called when the attribute is absent

reset it after every use

JasperException: ... According to TLD, tag [bs:boom] must be empty, but is not (observed)

a body inside a tag whose body-content is empty

remove the body, or change the declaration

JasperException: ... Scripting elements ( <%!, <jsp:declaration, <%=, ... ) are disallowed here. (observed)

a scriptlet in a scriptless body

use EL, or declare the body JSP (not recommended)

the body prints ${...} literally and the nested tags are not run (observed)

body-content is tagdependent

that is the point of the value; use JSP if the body is page content

the output of a body tag is missing

written to pageContext.getOut() (the buffer) instead of getPreviousOut()

use getPreviousOut() in doAfterBody

JspTagException: bs:position must be nested in bs:shelf (observed)

findAncestorWithClass returned null

throw a clear message, or validate in a TagExtraInfo or a TagLibraryValidator

a body is evaluated forever

doAfterBody always returns EVAL_BODY_AGAIN

return SKIP_BODY when the loop is done

EVAL_BODY_BUFFERED from a handler that does not implement BodyTag

the constant belongs to BodyTag

extend BodyTagSupport, or return EVAL_BODY_INCLUDE

cannot find symbol: EVAL_BODY_TAG after moving to Pages 4.0

the constant of JSP 1.1 was removed in 4.0

use EVAL_BODY_BUFFERED

a lock, a transaction or a file is never closed when the body fails

cleanup is in doEndTag, which is skipped on an exception

implement TryCatchFinally and close in doFinally

a scripting variable is null or not found by the compiler

no TagExtraInfo or variable element, or the wrong scope for the place where it is used

declare it, and choose NESTED, AT_BEGIN or AT_END for where it is needed

the rest of the page is missing after a tag

doEndTag returned SKIP_PAGE

return EVAL_PAGE unless you mean to end the request (a redirect tag, for instance)

NullPointerException when a page that uses tags runs (documented by the Jasper how-to)

poolTagsWithExtends is true but the page’s own base class does not call _jspInit()

leave it false, or make the base class call _jspInit() from init

the handler behaves differently under load than in a single-request test

pooling reuses the instance only after the first use

test each handler at least twice in a loop, with pooling on and off

What Changed Recently

Table 10. Classic handlers and their surroundings, from JSP 1.1 to Pages 4.0
Version Change

Jakarta Pages 4.0 (Jakarta EE 11, 2024)

BodyTag.EVAL_BODY_TAG is gone (it is in the 3.1 Javadoc and not in the 4.0 Javadoc; EVAL_BODY_BUFFERED replaces it); the classes deprecated in jakarta.servlet.jsp.el are removed; the classic API is otherwise unchanged. Pages 4.1 is a milestone (4.1.0-M2) that this page does not cover

Jakarta Pages 3.0 and 3.1 (Jakarta EE 9 and 10)

the package becomes jakarta.servlet.jsp.tagext; the namespace of the TLD and of web.xml becomes https://jakarta.ee/xml/ns/jakartaee; no change to the protocol

Tomcat 11.0.1, 10.1.33, 9.0.97 (November 2024)

the fix for CVE-2024-52318, the tag recycling vulnerability introduced by an earlier pooling change

JSP 2.1 (2006)

unified EL with deferred attributes (deferred-value and deferred-method in the TLD), JspIdConsumer, resource injection into tag handlers

JSP 2.0 (2003)

SimpleTag and tag files; EL evaluated by the container in attributes, which makes manual evaluation by the handler unnecessary; TagExtraInfo still supported; DynamicAttributes

JSP 1.2 (2001)

IterationTag with EVAL_BODY_AGAIN, TryCatchFinally, BodyTag.EVAL_BODY_BUFFERED (replacing EVAL_BODY_TAG), TagLibraryValidator, multiple TLDs per JAR, the TLD DTD

JSP 1.1 (1999)

the original Tag and BodyTag interfaces, TagSupport, BodyTagSupport, TagExtraInfo, VariableInfo

Where the Books Are Outdated

The books in the section bibliography that teach custom tags were written for JSP 1.1 and 1.2 and for JSTL 1.0. What they say about the classic API is still correct as far as it goes; what has changed around it is listed below.

Table 11. Outdated advice about classic handlers, and what applies today
The books say Today

packages javax.servlet.jsp.tagext and javax.servlet.jsp

jakarta.servlet.jsp.tagext and jakarta.servlet.jsp since Pages 3.0; recompile or run the Tomcat migration tool

the tag library descriptor is a DTD document with <!DOCTYPE taglib ...>, tlibversion and jspversion

an XML Schema document in the https://jakarta.ee/xml/ns/jakartaee namespace with tlib-version and a version attribute (the JSTL 3.0.1 jar itself still ships its 1.0 descriptors, see the JSTL Core page)

the body constant is BodyTag.EVAL_BODY_TAG (the JSP 1.1 name, still mentioned beside the 1.2 one)

removed in Pages 4.0; use EVAL_BODY_BUFFERED

a tag that accepts an expression evaluates it itself, with the Jakarta Taglibs ExpressionEvaluatorManager, in doStartTag (the JSTL 1.0 "EL" libraries and the _rt twin libraries)

the container evaluates every attribute whose TLD says rtexprvalue true, and the JSTL has been a single library per area since 1.1; remove the manual evaluator and add a plain setter. Manual evaluation also reopens the expression injection that the container closes

the handler is "pooled by the container" with no further warning, and a handler may keep state between calls

pooling is a Jasper option with real hazards (above); write every handler so that it works with pooling on and off

new tags are classic handlers because that is all there is

SimpleTag and tag files are the default for new tags since JSP 2.0; classic is for maintenance and for the JSTL base classes

scripting variables as the way to hand a value to the page

EL and var attributes; scripting variables remain for old scriptlet pages and are impossible in scriptless bodies

References