Tuesday, July 14, 2026

Making Mojarra fast: a per-phase performance review

Mojarra 4.1.9 was measurably slower than MyFaces 4.1.3. That was the whole of issue #5753: perform a performance review of the Jakarta Faces reference implementation and close or even surpass the gap where possible. This post walks through how the review was done and what came out of it, broken down per lifecycle phase. The story throughout is 4.1.10 against the 4.1.9 release; MyFaces is the yardstick it chases. Each phase where MyFaces ran faster marked a lever to find, and a few times the fix was simply to adopt what MyFaces already did, and for the rest Claude Code figured out it by itself. The short version: Mojarra 4.1.10 is 64% (almost 3 times) faster than the 4.1.9 release, and along the way overtook the current MyFaces 4.1.4-SNAPSHOT development build (a72f081e2) by 18%, faster on every one of six tested servers.

None of this is one clever trick. It is a few dozen small, individually-boring levers, each measured on its own, spread across the six phases of the request lifecycle. So let's first look at how they were found.

The benchmark: a WAR that touches every phase

You cannot optimize what you cannot measure, so an early deliverable of the review was a benchmark WAR under test/perf. The first cut was incomplete: it did not exercise the whole pipeline, and PROCESS_VALIDATIONS in particular was barely touched, so the real conversion and validation cost stayed invisible. So it was reworked round after round until every phase and every component was covered under a realistic load; components like h:selectOneRadio, h:outputScript and h:message were only added in later rounds.

It is a component-family matrix. Four iterating families, h:dataTable, ui:repeat, composite components and build-time unrolled c:forEach, each spanning up to six variants: a read-only GET, a full postback with per-row inputs, a two-level nested variant, a state-restore-only "build" postback, plus ajax twins. On top of that sit flat multi-section forms (text, textarea, select, checkbox, radio over managed converters, validators and Bean Validation), a happy and an unhappy validation path, two dynamic-component scenarios and a couple of trivial baselines. 32 scenarios in total, each firing exactly the phases you would expect: GET-only scenarios fire Restore View and Render Response, postbacks fire all six.

A Faces PhaseListener registered in the WAR times every phase of every request and accumulates the result per scenario into a shared PerfStats. A companion servlet exposes those accumulators at /perf-stats as a fixed-width text table or, with ?format=json, as JSON that diffs cleanly between runs; /perf-stats?reset=1 clears them. A single integration test drives the whole thing: it resets the stats, loops thousands of GETs and postbacks against a managed app server, then reads /perf-stats at the end to dump the per-scenario, per-phase count/total/avg/min/max table. The same WAR runs against GlassFish, WildFly, TomEE, Payara, OpenLiberty and Tomcat, and every server has a -myfaces twin that swaps in MyFaces instead of Mojarra, so the exact same request stream is measured on both implementations. It lives in the test/perf module of the Mojarra source tree on the 4.1 branch. Run after cloning https://github.com/eclipse-ee4j/mojarra in the repo folder:

git switch 4.1
mvn -pl impl,test/base -am clean install -DskipTests
cd test/perf

Then run the perf bench using one of the following commands:

mvn clean verify -Dperf=true -Pglassfish           # Mojarra on GlassFish (default)
mvn clean verify -Dperf=true -Pwildfly             # Mojarra on WildFly
mvn clean verify -Dperf=true -Ptomee               # Mojarra on TomEE
mvn clean verify -Dperf=true -Ppayara              # Mojarra on Payara
mvn clean verify -Dperf=true -Pliberty             # Mojarra on OpenLiberty
mvn clean verify -Dperf=true -Ptomcat              # Mojarra on Tomcat
mvn clean verify -Dperf=true -Pglassfish-myfaces   # MyFaces on GlassFish
mvn clean verify -Dperf=true -Pwildfly-myfaces     # MyFaces on WildFly
mvn clean verify -Dperf=true -Ptomee-myfaces       # MyFaces on TomEE
mvn clean verify -Dperf=true -Ppayara-myfaces      # MyFaces on Payara
mvn clean verify -Dperf=true -Pliberty-myfaces     # MyFaces on OpenLiberty
mvn clean verify -Dperf=true -Ptomcat-myfaces      # MyFaces on Tomcat

Each run will take a little over a minute before it prints a fixed-width text table with the numbers.

The versions are not fixed either. -Dmojarra.version picks the Mojarra jar on any profile and -Dmyfaces.version picks the MyFaces jar on the -myfaces twins, so a released version can be benched against the current build without a rebuild. For example, this explicitly runs the 4.1.9 release on Tomcat instead of the current build:

mvn clean verify -Dperf=true -Ptomcat -Dmojarra.version=4.1.9

The server versions themselves are knobs too. Each profile takes a matching -Dglassfish.version, -Dwildfly.version, -Dtomee.version, -Dpayara.version, -Dliberty.version or -Dtomcat.version, and the Tomcat profile additionally takes -Dweld.version and -DhibernateValidator.version for the Weld and Hibernate Validator jars it bundles into the WAR. For example, this pins the Tomcat container version explicitly instead of using the default version hardcoded in test/pom.xml:

mvn clean verify -Dperf=true -Ptomcat -Dtomcat.version=11.0.22

The state-saving knobs, the heap size and the iteration counts are all filtered into the WAR at package time, so a whole matrix of state-saving configurations or even custom context parameters can be applied without editing anything. Every server launches with the same -Xmx1g so the cross-server comparison stays apples-to-apples.

The workflow: measure, profile, prototype, discard

The bench tells you which phase is slow. It does not tell you why. For that the same WAR doubles as a JFR driver: append the flight-recorder flags to the forked server VM, run at a tighter iteration count, and you get a per-method CPU profile and per-call-site allocation profile of the entire request stack, Mojarra, EL, CDI, Servlet, the server and the JDK all together. The jfr tool ranks the hot methods and the top allocators; a small script attributes each execution sample to a lifecycle phase by scanning its stack for the phase class.

This is where AI-assisted development earned its keep. The loop, run the A/B bench back-to-back on a quiesced machine (performance governor, no background load, no temperature throttles), profile the slow phase, read the JFR hot-method and allocation rankings, form a hypothesis, prototype a lever, rebuild both arms, remeasure, was run hundreds of times. Claude Code drove the harness, ran the mvn commands, injected the JFR flight-recorder flags into the forked server VM, generated the scripts, aggregated the raw per-phase dumps into readable delta tables, cross-checked JFR self-time against actual wall time, and kept the bookkeeping honest across dozens of candidate changes. That last part matters more than it sounds: a handful of candidate levers do not survive measurement. For example, caching the composite ValueExpression in TagAttributeImpl looked obvious and was prototyped and reverted three separate times, each time about 2% slower, because MyFaces caches it too and spends more there, and VE creation is only ~1.7% of the phase to begin with. An attributesThatAreSet bitmask regressed buildView by ~20%. A whole class of "obvious" micro-optimizations measured at exactly zero. Only the levers that held up under a clean back-to-back A/B shipped.

A recurring trap worth calling out: JFR self-time percentage is not wall-clock cost. A phase that is thin in wall time starves for samples, and a subtree that shows 9% self-time can move the needle 0% when you actually fix it. The bench wall time, not the profiler, was always the final word.

The machine itself is the other thing that lies to you. A performance run is only as honest as the box it runs on, and a box that is busy, or hot, or on a scaling governor hands you swings the size of a real lever. The early rounds showed it plainly. WildFly came out surprisingly fast and OpenLiberty surprisingly slow, and neither had anything to do with the code: the six-server run was sequential, and a long sequential run heats the CPU until it throttles, so whichever server ran late looked slow. The fix is boring. Pin the governor to performance, drop the background load, let the machine cool between servers, and run each arm back-to-back so the two builds you compare meet the same conditions. A change that only shows up on a loaded or throttling box is not a lever; it is weather. Every number in this post was taken on a quiesced machine for that reason.

The container can lie to you too, and profiling the whole stack is what caught it. Because the JFR run captures CDI and the app server alongside Mojarra, an early GlassFish profile showed almost half of the HTTP-listener CPU going into GlassFish's own InjectionServicesImpl, not into Faces at all. Every CDI injection was re-walking the superclass and annotation hierarchy, which a view full of @FacesConverter and @FacesValidator injections hits hard. That was a GlassFish bug, not a Mojarra one, and it made the initial GlassFish bench run about twice as slow. It was reported and fixed in GlassFish 8.0.3 (glassfish#26046); if you reproduce the bench on 8.0.2 you will see that inflation, so use 8.0.3 or later. It hit both implementations equally, so it never tilted the Mojarra-versus-MyFaces comparison, but it is exactly the kind of container cost a Faces-only view would have hidden.

Now the levers, phase by phase.

Restore View

Restore View rebuilds the component tree on every postback and restores its partial state. Mojarra was allocating heavily here. The biggest single lever was #5761: the descendant-id lookup used a per-tree HashMap cache that was rebuilt eagerly; it was replaced with a refresh-gated direct scan, and UIComponentBase.findComponent stopped allocating a fresh collection at every node it walked. #5819 cut further allocation on the unrolled c:forEach restore path, and #5828 memoizes the c:forEach items expression so ForEachHandler evaluates it once per phase instead of on every access. #5822 skips the duplicate-id uniqueness check on postbacks that reuse the already-restored tree, the way MyFaces does, plus two smaller lifecycle levers.

A subtle one came in via #5853, backporting jakartaee/faces#2209: an h:selectOneRadio's @ListenerFor listener was being persisted as a needless per-component partial-state delta on every request. A single non-empty component delta is enough to force the full O(N) clientId-keyed restore walk instead of the cheap view-root-only fast path. Persisting the system-event listeners in the delta only when they actually changed brought the flat-form Restore View back to parity in isolation.

Net Restore View dropped about 65% versus 4.1.9, landing within a few percent of MyFaces; what's left is the shared buildView Facelet re-apply that MyFaces pays too. Notably the unrolled views, which carry the most components and the most per-component state of anything in the suite, are already faster than MyFaces here.

Several smaller levers round out the phase: #5764 (a dynamic-add gate, field-backed Facelets markers and ancestor memoization on the restore walk), #5778 (partial-state-saving and tree-walk trims), #5800 (cheaper per-component createComponent in buildView), #5805 (field-backed component value expressions), #5836 (an indexed Facelets tag-id lookup on refresh) and #5813 (dropping a per-component reflective composite-probe from the restore walk).

Apply Request Values

Apply Request Values decodes the submitted values onto the components. This is per-component overhead territory, and the fix is boring: #5777 reduced the fixed cost paid at every component in the pipeline. There is no single hotspot here; it is a broad, flat cost that only comes down by shaving every node a little. The phase came down about 54% versus 4.1.9, and now runs 18% faster than MyFaces across the suite; the win is dead consistent across all six servers, which is the tell that it is structural and not host-specific.

#5798 reads disabled and readonly through typed getters on decode instead of the reflective attribute map. Two broader request-pipeline levers land hardest in this phase, decisively faster on all six servers, though they touch every phase: #5757 (a FacetsMap empty short-circuit, a descriptor-map computeIfAbsent and hoisted UIData/UIRepeat restore state) and #5759 (per-component state access, a NamingContainer-ancestor cache, per-row state, indexed traversal, event publishing and render-output coalescing).

Process Validations

Of every phase this one fell the furthest against 4.1.9, by nearly 79%. It was also the closer: after the other phases had landed, Process Validations was the one still trailing MyFaces. Once the bench exposed the real conversion and validation load, it measured 44% slower than MyFaces; it is now 32% faster, and Mojarra leads in every component family. It came mainly from three PRs.

#5838 moved the java.time formatters used by f:convertDateTime to application scope instead of rebuilding them per conversion. #5840 shared a single application-scoped BeanValidator, cached the NumberConverter parser, and made UIInput's per-request validation flags (valid, localValueSet, submittedValue) cheaper: they live in transient state, and a default value now costs no map lookup or allocation, so an input whose flags are all default holds no transient map at all, saving a per-input map hash across a table's rows. An intermediate experiment had field-backed those flags as plain fields instead; a MyFaces maintainer rightly flagged that as fishy in the review thread, since per-request lifecycle state does not belong in persisted fields, so it was reverted to transient state and the transient get/put path was optimized instead.

The big one was #5847. Mojarra re-resolved the EL root once per input per validation, and each re-resolution allocated a fresh CDI CreationalContext. On a form with dozens of inputs that is dozens of pointless CDI allocations per request. After the fix, UIInput.validate also skips reading the previous value when nothing observes a value change, and the bean property type is taken from the already-resolved ValueReference instead of a second BeanValidator.getType walk. The form-* family, which carries all the new conversion work the bench added, dropped from behind MyFaces to parity, and the composite family, which had been the single biggest deficit anywhere in the suite at +18%, inverted to 28% faster once the composite cc.attrs EL depth was addressed with #5843 and #5847 (composite component stacks backed by ArrayList, non-contributing ELResolvers skipped, the postback check gated behind the composite check).

Four more levers finish the phase: #5767 (eliminating nested UIData/UIRepeat per-row state cost), #5782 (a guard plus per-attribute memoization in place of the composite-expression cache), #5831 (converter formatter caching and reuse of the built-in by-type converters) and #5834 (a cached parse formatter in DateTimeConverter).

Update Model Values

Update Model Values pushes the validated values into the backing beans, so it walks the same per-component and EL resolution paths as Apply Request Values and Process Validations. It did not get its own dedicated PR; it rode along on the per-component overhead work behind Apply Request Values and the validation EL work behind Process Validations, coming down about 63% versus 4.1.9 and landing ~9% faster than MyFaces. Worth naming as its own phase because that shared-cost effect is the point: a per-component or EL-resolution lever fixed once pays off in every phase that walks the tree.

Invoke Application

Invoke Application is sub-100µs per request in every scenario except the dynamic-component ones which programmatically modify the component tree during an action, so in absolute terms it is too small to matter; the aggregate percentage is a rough indicator only. But the dynamic add/remove machinery the dynamic-component scenarios drive was genuinely broken and got a cluster of fixes. #5783 made dynamic-component restore O(N) instead of O(N²). #5785 fixed a regression where a full-tree clientId index was being built on every postback even when there were zero dynamic actions, and trimmed CDI and event-dispatch overhead on the same path. The #5853 backport landed two more: skipping the redundant dynamic-child reorder in buildView when the children are already in order, and trimming the add/remove path further while removing the dead TREE_HAS_DYNAMIC_COMPONENTS view-root flag that had been write-only since #5761 (for Restore View) dropped its only reader. Finally #5791 trims further per-component overhead on that same add/remove path.

One dynamic case is deliberately left alone, and it comes down to how each impl carries dynamic structure across a postback. Rather than persist an added subtree as full component state the way MyFaces does, Mojarra records each add or remove as a compact action (a ComponentStruct) and replays that action list when the view is rebuilt. It defers that replay to Render Response, because an Invoke Application action may still navigate away; deferring means Mojarra only ever re-applies the actions to the view it actually renders, never one it discards. The subtree itself is fully in the tree from Restore View onward, so nothing is withheld from decode, validate or update; only the render-time re-apply is deferred. That is why the dynamic-toggle-ajax scenario, which toggles on the same view, renders slower than MyFaces while its Restore View is faster; net it is a wash. A sound state-model trade, not a defect, and "fixing" it would break the navigation case.

Render Response

Render Response is the heaviest phase in absolute terms, so even modest percentages here move the suite total more than anything else. It came down about 58% versus 4.1.9 and is now 20% faster than MyFaces on all six servers. The largest lever was #5812: for static views Mojarra was re-applying the Facelet at render time even though nothing had changed since the build, a redundant tree walk that was skipped. #5796 attacked a subtler, per-component cost: the renderers were reading standard attributes like styleClass through getAttributes().get(...), which routes through the reflective AttributesMap, on every component they encoded. Casting to the concrete component subclass and calling its typed getter (getStyleClass() and friends) skips the reflection entirely, and the read is skipped altogether when the attribute is unset. #5822 does the same for the pass-through attribute sweep, which is skipped when a component declares none. Finally #5785 also lives partly here: a @FacesConverter(managed=true) was being re-resolved through CDI for every single table cell, which a small Bean cache eliminated, and the per-event listener dispatch got a fast path that took it off the top of the allocation profile.

The remaining Render Response levers are a spread of per-component and output trims: #5752 (caching CDI bean resolution to remove per-render BeanManager lookups), #5755 (response-writer range-emit and buffer elimination), #5770 (a lazy state map, UIOutput converter, rendererType and AttributesMap getter), #5793 (per-component encode and view-build overhead), #5825 (skipping the render re-apply of static c:forEach and deduping ViewScope FacesContext lookups) and #5839 (emitting an event-handler attribute only once when set via expression).

The numbers

Six servers, default settings (partial state saving, server state saving, Production stage), 1000-run suite, JDK 21, all three implementations built back-to-back on a quiesced machine. Values below are aggregated across servers; negative means Mojarra 4.1.10 is faster.

phasevs Mojarra 4.1.9vs MyFaces
Restore View−65%−4%
Apply Request Values−54%−18%
Process Validations−79%−32%
Update Model Values−63%−9%
Invoke Application−56%+22% †
Render Response−58%−20%
whole suite−64%−18%

† Invoke Application is sub-100µs per request outside the dynamic-toggle-ajax scenario, so its aggregate percentage is a rough indicator, not a real cost.

The vs-4.1.9 column is the cumulative gain since the last release: 64% faster, dead consistent across every clean server at −64 to −65%. The vs-MyFaces column is measured against the current MyFaces 4.1.4-SNAPSHOT development build (a72f081e2), itself 22% faster than its own 4.1.3 release, so this is Mojarra ahead of MyFaces at its best, not against a stale yardstick. The wins are structural: the tight cross-server spread on Apply Request Values and Render Response is what tells you these are real code changes and not one lucky host.

The same levers ship across all three active release lines: authored on the 4.1 branch or backported from master, they are all present in 4.1.10, backported to 4.0.19, and forward-ported to 5.0.0-M3.

What made the iteration fast

A performance review is only as fast as your validation loop, because every lever has to pass the full Jakarta Faces TCK before it can ship. A lever that shaves 3% is worthless if it quietly breaks a spec-mandated edge case, and the only way to know is to run the TCK, all of it.

Not long ago that meant hours. The Faces TCK ran the old JavaTest harness through repeated GlassFish cold starts and took over three hours end to end, which is fatal to an iterate-measure-validate loop; you get one or two validated levers per day and you lose your train of thought between them. The GlassFish pool work described in From hours to minutes: GlassFish pool for Jakarta EE TCKs brought that same TCK down to under four minutes with a reusable, leased server pool and parallel execution.

Four minutes changes the character of the work. A candidate lever could be prototyped, benched, and TCK-validated within an hour, so it was cheap to try a change, measure it, and throw it away when it did not pan out; and, as noted, a handful did not. The speed of the TCK is what let the review be exhaustive rather than conservative. 64% faster than the previous release is the combined result of being able to afford that many attempts in a relatively short time.

The fast TCK was only half of the loop; the other half was that same Claude Code workflow on the authoring side. Drafting a candidate lever, then forward- and backporting it across the release lines once it held up, is mechanical work that scales badly by hand. The judgement stayed human: which lever is worth shipping, whether a change is spec-legal, and reading the TCK output when a change was not. What came off your hands was the bulk, and for a review this wide that bulk is most of the hours.

The traffic went both ways. Digging into why Restore View was slow surfaced a corner case worth guarding: a UICommand bound into an h:dataTable via binding must fire its action exactly once per click instead of multiple times, and an earlier Restore View fix (#4128) was the thing keeping it that way. Analyzing that fix, because it badly impacted performance, turned up that its regression test, which had guarded exactly this behaviour, was nowhere in the TCK: it had been dropped from Mojarra's own test tree in the 3.0-to-4.0 migration and never migrated across. So it was ported back into the TCK as jakartaee/faces#2179, so that no Restore View optimization could quietly reintroduce the double-fire.

That one test exposed a bigger gap: Mojarra's old suite of 577 integration tests had been dropped wholesale in that migration, and only a part was ever migrated into the TCK, because porting each one by hand was too much work to justify. With AI assistance it no longer was. jakartaee/faces#2181 resurrected 138 of the spec-relevant ones with help of Claude Code, filtering out the ones not yet covered by the existing tests, each ported from the old HtmlUnit harness to Selenium, modernised to Jakarta namespaces and CDI, and named after the issue it covers. Those resurrected tests promptly caught three real Mojarra regressions, unrelated to the performance work, fixed in #5774. And the pool swallowed all 138 new tests without complaint: the full 6026-test 5.0 TCK still finishes in less than four minutes at my machine with -T8.

The bottom line

"Mojarra is slow" was a fair thing to say against 4.0.18, 4.1.9 and 5.0.0-M2 or earlier. Now it does not hold anymore. Since 4.0.19, 4.1.10 and 5.0.0-M3, Mojarra is not just almost 3 times faster than its own previous release, it runs ahead of MyFaces at its best, on every one of six tested servers. If you last benchmarked Mojarra a release or two ago and wrote it off, the number you remember is stale; the reputation outlived the reality, and the numbers have caught up.

Appendix: all levers at a glance

Every lever from the sections above, collected in one place and grouped by the phase it moves most. All are present in 4.0.19, 4.1.10 and 5.0.0-M3: those authored on the 4.1 branch landed directly; those authored on master reached the release lines as a backport, shown as primary (backport). A handful of small regression-guard fixes that accompanied the perf work are omitted. faces#NNNN refers to a change in the Jakarta Faces API repository.

phasePRwhat it does
Restore View#5761Descendant-id HashMap cache replaced with a refresh-gated direct scan; findComponent stops allocating per node
#5764Dynamic-add gate, field-backed Facelets markers, ancestor memoization on the restore walk
#5778Partial-state-saving and component-tree-walk improvements
#5800Cut per-component createComponent overhead in buildView
#5805Field-back component value expressions to cut restore cost
faces#2191 (#5813)Drop the per-component composite-probe reflective lookup from the restore walk
#5818 (#5819)Reduce restore allocation on JSTL c:forEach views
#5821 (#5822)Skip the id-uniqueness check on tree-reusing postbacks, plus two more view-lifecycle levers
#5828 (#5831)Memoize the c:forEach items expression per phase
#5835 (#5836)Index the Facelets tag-id lookup on refresh
faces#2209 (#5853)Persist system-event listeners in the partial-state delta only when changed
Apply Request Values#5757FacetsMap empty short-circuit, descriptor-map computeIfAbsent, UIData/UIRepeat restore-state hoist, indexed child traversal
#5759Per-component state/property access, NamingContainer-ancestor cache, per-row state, indexed traversal, event publishing, render-output coalescing
#5777Reduce per-component request-pipeline overhead
#5798Read disabled/readonly via typed getters on decode; less StateHelper and reflective overhead
Process Validations#5767Eliminate nested UIData/UIRepeat per-row state cost
#5782Replace the composite-expression cache with a guard plus per-attribute memoization (cc.attrs)
#5829 (#5831)Reuse the built-in by-type converters per target class
faces#2199 (#5831)Cache converter formatters/parsers
faces#2201 (#5838)Move the java.time f:convertDateTime formatters to application scope
#5840Shared BeanValidator, cached NumberConverter parser, lazy transient UIInput state
#5843EL resolution, composite component (ArrayList-backed stacks, non-contributing ELResolver skip) and component-id
#5847No per-input EL-root re-resolution; skip the previous-value read when unobserved; bean type from the ValueReference
Invoke Application#5783Dynamic component add/remove restore made O(N) instead of O(N²)
#5785Dynamic-action zero-actions regression fix, plus CDI and event-dispatch overhead
#5791Trim per-component overhead on the dynamic add/remove path
#5848/#5851 (#5853)Skip the redundant dynamic-child reorder; trim the add/remove path; remove the dead TREE_HAS_DYNAMIC_COMPONENTS flag
Render Response#5752Cache CDI bean resolution to eliminate per-render BeanManager lookups
#5755Response-writer range-emit and buffer elimination
#5770Lazy state map, UIOutput converter, rendererType, AttributesMap getter
#5793Reduce per-component overhead in encode and view build
#5796Read renderer attributes (e.g. styleClass) via typed getters instead of the reflective AttributesMap; skip when unset
#5811 (#5812)Skip the render-time Facelet re-apply for static views
#5824 (#5825)Skip the render re-apply of static c:forEach; dedup ViewScope FacesContext lookups
#5839Render an event-handler attribute only once when set via expression

All of it was validated on GlassFish, WildFly, TomEE, Payara, OpenLiberty and Tomcat. The full breakdown, round by round, is on issue #5753.

No comments: