Deployment and Clustering

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 Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11 and Servlet 6.1), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later (the examples were compiled with --release 21). The clustering experiments ran two Apache Tomcat 11.0.26 nodes with Weld 6.0.4.Final, once with Mojarra and once with MyFaces, behind Apache HTTP Server 2.4.67, and were driven with scripted HTTP requests; every result quoted below was observed on 2026-10-08. Only Faces 4.1 was run; the mechanisms described here exist in Faces 4.0 (Jakarta EE 10) as well, and the differences between the releases are collected in What Changed per Version. Faces 5.0 is still a milestone release and nothing here describes it as current behaviour. What was not run (Docker builds, the nginx and Kubernetes files, WildFly and Payara clusters) is marked where it appears.

A single Faces application on a single server hides a lot. The view state is in the session of that one JVM, the flash is in memory, the @ApplicationScoped beans are the only instances, and a restart is the only way to lose anything. Deployment changes that in two steps. The first is packaging: the WAR is built once, the same file runs in every environment, and what changes between environments is configuration. The second is scale and availability: with two nodes behind a load balancer, a postback can reach a node that has never seen the page, and a node can disappear between a request and the next one. Faces has a precise answer to the question "where does my state live", and that answer decides what the cluster must provide. This page follows the path from the WAR to a failing node: packaging, the Production stage, containers, the proxy in front, then the three strategies (sticky sessions, session replication, client state) and the traps in each.

The page is about the Faces-specific parts of deployment. Docker, Kubernetes and the cloud load balancers have their own sections of this guide, linked in Related Pages.

The Bookshelf Deployment Used on This Page

The scenario is the Bookshelf application of the other pages, with a few additions that exist only to watch state move:

Class Purpose

DraftBean

a @ViewScoped bean with a counter that each Save postback increments; its id field shows which instance a node is using

ReadingList

the visitor’s reading list as a @SessionScoped bean (Changes Made in Place Are Not Replicated)

ReadingListStore

the same list kept as an immutable value in one session attribute, the replication-safe version

NodeHeaderFilter

adds an X-Node response header with the name of the node (Monitoring)

ReadinessServlet

/health/ready, a probe for the load balancer (Monitoring)

SessionReplicationFilter

an optional remedy for state changed in place (Changes Made in Place Are Not Replicated)

Both nodes run the same exploded WAR on ports 8760 and 8761; the proxy listens on 8762. In the tables below, "counter" is the value of the view bean’s counter after three Save postbacks on node A and one more on node B, so 4 means that node B continued with the same bean instance node A used, and 1 means that it found an older or a new one.

Packaging the WAR

A Faces application is a standard WAR: WEB-INF/classes, WEB-INF/lib, the XHTML views and resources at the root (or under src/main/webapp in a Maven project), WEB-INF/web.xml and WEB-INF/beans.xml. What decides the contents is who provides Faces. On a Jakarta EE server (GlassFish, WildFly, Payara, Open Liberty, TomEE) the server has the Faces implementation and Weld or OpenWebBeans, and the WAR holds only the application and the compile-time API as provided. On a plain servlet container such as Tomcat the WAR carries the implementation, a CDI container and the annotations API itself. One pom.xml with a profile covers both, and both were built:

pom.xml: provided by default, bundled with -Ptomcat
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>org.example</groupId>
  <artifactId>bookshelf</artifactId>
  <version>1.0.0</version>
  <packaging>war</packaging>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <!-- A fixed timestamp makes two builds of the same commit byte-for-byte equal. -->
    <project.build.outputTimestamp>2026-10-08T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <dependencies>
    <!-- Compile against the platform API; the server provides the implementation. -->
    <dependency>
      <groupId>jakarta.platform</groupId>
      <artifactId>jakarta.jakartaee-web-api</artifactId>
      <version>11.0.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>bookshelf</finalName>
    <plugins>
      <plugin>
        <artifactId>maven-war-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
    </plugins>
  </build>

  <profiles>
    <!-- mvn -Ptomcat package: a plain servlet container needs Faces, Weld and the annotations in the WAR. -->
    <profile>
      <id>tomcat</id>
      <dependencies>
        <dependency>
          <groupId>org.glassfish</groupId>
          <artifactId>jakarta.faces</artifactId>
          <version>4.1.17</version>
        </dependency>
        <dependency>
          <groupId>org.jboss.weld.servlet</groupId>
          <artifactId>weld-servlet-shaded</artifactId>
          <version>6.0.4.Final</version>
        </dependency>
        <dependency>
          <groupId>jakarta.annotation</groupId>
          <artifactId>jakarta.annotation-api</artifactId>
          <version>3.0.0</version>
        </dependency>
      </dependencies>
    </profile>
  </profiles>
</project>

The default build produced a WAR of 22,519 bytes with no libraries in WEB-INF/lib. With -Ptomcat it produced 6.1 MB containing jakarta.faces-4.1.17.jar, weld-servlet-shaded-6.0.4.Final.jar and jakarta.annotation-api-3.0.0.jar. Which libraries a given server needs, and what happens when the WAR carries a second implementation that the server also has, is the subject of Jakarta EE Servers (Tomcat and Weld covers the case where you bring the implementation); the short version observed there is that a WAR with its own Faces implementation can clash with the one the server provides, so decide per target and keep the dependency scope honest.

Reproducible builds. A deployment pipeline is easier to trust when two builds of the same commit give the same file. The property project.build.outputTimestamp in the POM above fixes the timestamps in the archive; building the Tomcat variant twice, with a clean and a pause in between, produced the same SHA-256 (18794ab4... in the run) both times. The benefit for operations is that the checksum of the WAR identifies the build and a changed checksum means changed content.

web.xml. A WAR no longer needs one, because Faces 4 registers FacesServlet itself, but a production WAR usually has one for the parts that are decisions of the deployment: the project stage, where the view state lives, the session cookie, the error pages and <distributable/>. The file used for every run in the clustering sections is below; the elements are explained in the sections that use them. It validates against the Servlet 6.1 schema (web-app_6_1.xsd).

src/main/webapp/WEB-INF/web.xml
<?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">

    <display-name>bookshelf</display-name>

    <!-- Lets the container replicate or persist sessions; every fragment in WEB-INF/lib must agree. -->
    <distributable/>

    <context-param>
        <param-name>jakarta.faces.PROJECT_STAGE</param-name>
        <param-value>Production</param-value>
    </context-param>
    <context-param>
        <param-name>jakarta.faces.FACELETS_REFRESH_PERIOD</param-name>
        <param-value>-1</param-value>
    </context-param>
    <context-param>
        <param-name>jakarta.faces.FACELETS_SKIP_COMMENTS</param-name>
        <param-value>true</param-value>
    </context-param>
    <context-param>
        <param-name>jakarta.faces.STATE_SAVING_METHOD</param-name>
        <param-value>server</param-value>
    </context-param>
    <!-- Mojarra only: re-set session attributes so that replication sees the changes. MyFaces ignores it. -->
    <context-param>
        <param-name>com.sun.faces.enableDistributable</param-name>
        <param-value>true</param-value>
    </context-param>

    <servlet>
        <servlet-name>facesServlet</servlet-name>
        <servlet-class>jakarta.faces.webapp.FacesServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>facesServlet</servlet-name>
        <url-pattern>*.xhtml</url-pattern>
    </servlet-mapping>

    <session-config>
        <session-timeout>30</session-timeout>
        <cookie-config>
            <http-only>true</http-only>
            <secure>true</secure>
        </cookie-config>
        <tracking-mode>COOKIE</tracking-mode>
    </session-config>

    <error-page>
        <exception-type>jakarta.faces.application.ViewExpiredException</exception-type>
        <location>/expired.xhtml</location>
    </error-page>
    <error-page>
        <error-code>500</error-code>
        <location>/error.xhtml</location>
    </error-page>

    <welcome-file-list>
        <welcome-file>index.xhtml</welcome-file>
    </welcome-file-list>
</web-app>

Two details of that file are worth a sentence each. <distributable/> is the application’s promise that it can be spread over several JVMs: every session attribute must then be serialisable, and the container is free to replicate or persist sessions. The <session-config> makes the session cookie HttpOnly and Secure; when the nodes sit behind a proxy that ends TLS, the Secure flag is set from the configuration and does not depend on the proxy (The Reverse Proxy in Front of the Nodes shows the other half). The beans.xml is the one every Faces application has:

src/main/webapp/WEB-INF/beans.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans 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/beans_4_1.xsd"
       version="4.1" bean-discovery-mode="annotated"/>

On Tomcat the application needs nothing else in web.xml: the WAR built with -Ptomcat, unpacked and run on the two-node cluster without an explicit Weld listener, served pages and replicated sessions on both nodes. The context root is the name of the WAR (bookshelf.war serves /bookshelf; a WAR called ROOT.war serves /).

The Production Stage on a Server

jakarta.faces.PROJECT_STAGE is Production when it is absent, and a mistyped value is also Production, so the setting that matters in an environment is the one that must be absent from it: nothing may switch a server that real users reach to Development. How the stage is resolved (a JNDI entry beats the context parameter), what each stage changes and how to select it per environment is on the project stages section of the configuration page; the cost of running anything else is measured on the performance page. For a deployment the checklist is short:

Setting Production value and reason

jakarta.faces.PROJECT_STAGE

Production, written in the WAR so that the intent is visible; override only through the JNDI entry faces/ProjectStage of a development environment

jakarta.faces.FACELETS_REFRESH_PERIOD

-1: do not check the view files for changes on every request

jakarta.faces.FACELETS_SKIP_COMMENTS

true: XML comments in templates are not sent to the browser

jakarta.faces.STATE_SAVING_METHOD

written explicitly (server or client), because a cluster is configured around it (Where the View State Lives, and What the Cluster Must Provide)

an error-page for ViewExpiredException and for 500

a friendly page of your own; see ViewExpiredException handling and the error pages

the container’s own error report

switched off, see below

the session cookie

HttpOnly, Secure, SameSite; see the cookies section of the security page

The container’s own error page. A request that fails without an error-page mapping gets the server’s default report. On Tomcat 11.0.26 that report shows the exception message and a stack trace. The run that follows posted a page rendered by node A to node B without a session (the cluster-failure case of the next sections), which ends in a ViewExpiredException:

Tomcat’s default 500 page, and the same request after the ErrorReportValve below
without the valve:   2198 bytes   Exception Report, Message, Description, Exception, stack trace ...
with the valve:       451 bytes   <h1>HTTP Status 500 - Internal Server Error</h1>

The valve is one element in the <Host> of server.xml:

server.xml: an error report that says nothing about the server
<Valve className="org.apache.catalina.valves.ErrorReportValve"
       showReport="false" showServerInfo="false"/>

Faces' own error handling (the error-page elements, an ExceptionHandler) answers first for exceptions that reach Faces; the valve is the last line of defence for everything else. Other servers have their equivalents; the point is that a stack trace and a server version are information for the attacker and for nobody else.

Docker Images for Tomcat, WildFly and Payara

A container image puts the WAR, the server and its configuration in one artifact. The Docker pages of this guide cover the mechanics (Dockerfile fundamentals, Java images and image best practices); this section is about what is specific to a Faces WAR on three servers. The image names and tags below were checked against the registries on 2026-10-08, and the paths and users against the images' own Dockerfiles. The images were not built, because no Docker daemon ran in the scratch environment.

Tomcat. The official image is tomcat:11.0.26-jdk21-temurin-noble (tags with jre instead of jdk and with Java 17 and 25 exist). Its webapps directory is empty, Tomcat runs as root unless told otherwise, and the console receives the log. A WAR copied to /usr/local/tomcat/webapps is deployed at start:

Dockerfile for the Tomcat image: a non-root user
FROM tomcat:11.0.26-jdk21-temurin-noble

# The official image runs Tomcat as root and ships an empty webapps directory.
RUN groupadd --system --gid 10001 bookshelf \
 && useradd --system --uid 10001 --gid bookshelf --no-create-home bookshelf \
 && chown -R bookshelf:bookshelf /usr/local/tomcat/logs /usr/local/tomcat/temp /usr/local/tomcat/work

COPY --chown=bookshelf:bookshelf target/bookshelf.war /usr/local/tomcat/webapps/bookshelf.war
COPY --chown=bookshelf:bookshelf docker/server.xml /usr/local/tomcat/conf/server.xml

USER bookshelf
EXPOSE 8080
# CMD ["catalina.sh", "run"] is inherited from the base image; Tomcat logs to the console.

WildFly. The image is quay.io/wildfly/wildfly:41.0.1.Final-jdk21 (the old jboss/wildfly image on Docker Hub is no longer updated). It runs as the jboss user, starts standalone.sh -b 0.0.0.0, and the deployment scanner of the default configuration deploys what is in /opt/jboss/wildfly/standalone/deployments/. The default standalone.xml has no clustering; the high-availability configuration is standalone-ha.xml, selected with --server-config:

Dockerfile for the WildFly image
FROM quay.io/wildfly/wildfly:41.0.1.Final-jdk21

# The deployment scanner of the default standalone configuration picks the WAR up.
COPY --chown=jboss:root target/bookshelf.war /opt/jboss/wildfly/standalone/deployments/bookshelf.war

# standalone-ha.xml adds the clustering subsystems that distributable web applications use.
CMD ["/opt/jboss/wildfly/bin/standalone.sh", "-b", "0.0.0.0", "--server-config=standalone-ha.xml"]

Payara. payara/server-full:7.2026.9 exists (a -jdk25 variant too) and runs the server’s JVM as the payara user. The entry point deploys every WAR it finds in /opt/payara/deployments after the domain starts, and the image configures the server log to go to the console. Its Dockerfile sets the administration password to a well-known literal at build time, so the administration port (4848) and the debug port (9009) must never be published; only 8080 and 8181 are the application’s:

Dockerfile for the Payara image
FROM payara/server-full:7.2026.9

# The entry point of the image deploys every WAR it finds in this directory.
COPY --chown=payara:payara target/bookshelf.war /opt/payara/deployments/bookshelf.war

What every image has in common is how it learns its secrets. The client-state keys of Client State Saving Across Nodes, a database password or an OpenID Connect client secret reach the container as environment variables or mounted files, from a secret store, never as a line of the Dockerfile and never as a file in the WAR (the security page lists the rules). A value written in a Dockerfile is in every layer of the image and in its history.

Compose and Kubernetes

Two nodes behind a proxy is the smallest cluster, and Compose describes it in one file. docker compose config accepted the file below (syntax and schema; the stack was not started). The key values come from the caller’s environment or from an untracked .env file, and no key is in the file:

compose.yaml: two nodes and a proxy
services:
  bookshelf-a:
    build: .
    environment:
      NODE_NAME: node-a
      FACES_CLIENT_SECRET:        # passed through from the shell or from an untracked .env file
      MYFACES_SECRET:
      MYFACES_MAC_SECRET:
    expose: ["8080"]

  bookshelf-b:
    build: .
    environment:
      NODE_NAME: node-b
      FACES_CLIENT_SECRET:
      MYFACES_SECRET:
      MYFACES_MAC_SECRET:
    expose: ["8080"]

  proxy:
    image: nginx:1.29
    ports: ["8080:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [bookshelf-a, bookshelf-b]

On Kubernetes the same idea is a Deployment with two replicas and a Service. The parts that matter to Faces are the readiness probe (a node that is starting, or leaving, must not receive postbacks: Monitoring), the secret injected as an environment variable, a pod name as the node name, and a grace period long enough for requests in flight. This manifest parses as YAML; it was not applied, because no cluster was available:

k8s.yaml: Deployment and Service
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf
spec:
  replicas: 2
  selector:
    matchLabels: { app: bookshelf }
  template:
    metadata:
      labels: { app: bookshelf }
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: bookshelf
          image: registry.example.com/bookshelf:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: NODE_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: FACES_CLIENT_SECRET
              valueFrom: { secretKeyRef: { name: bookshelf-faces, key: client-secret } }
          readinessProbe:
            httpGet: { path: /bookshelf/health/ready, port: 8080 }
            periodSeconds: 5
          lifecycle:
            preStop:
              exec: { command: ["sleep", "10"] }   # let the load balancer notice that the pod is leaving
---
apiVersion: v1
kind: Service
metadata:
  name: bookshelf
spec:
  selector: { app: bookshelf }
  ports:
    - port: 80
      targetPort: 8080

A Service load-balances per connection, not per session, so the manifest gives Faces no stickiness at all: every strategy of the sections below must come from the application (replication, client state) or from the ingress or gateway in front of the Service (cookie affinity). The Kubernetes pages of this guide cover the objects: health probes and lifecycle, Services, Ingress, Gateway API, Secrets and deployment strategies.

The Reverse Proxy in Front of the Nodes

Production traffic reaches a Faces application through a reverse proxy or load balancer that ends TLS, spreads the requests, and (sometimes) sends a session back to the node that holds it. The application server sees plain HTTP from the proxy’s address. Four things about that arrangement concern Faces.

1. The scheme and the client address. The proxy tells the node what the browser used with X-Forwarded-Proto, and the node must be told to believe it, but only from the proxy. On Tomcat that is the RemoteIpValve. The run below sent the same request with X-Forwarded-Proto: https, once to a node without the valve and once to a node with it; the Secure flag of the session cookie that Tomcat created is the visible difference (the redirect of a faces-redirect=true navigation was the relative /bookshelf/draft.xhtml in both runs, so the redirect itself does not depend on the scheme):

The effect of the RemoteIpValve on the session cookie (X-Forwarded-Proto: https)
without the valve:  Set-Cookie: JSESSIONID=...node-a; Path=/bookshelf; HttpOnly
with the valve:     Set-Cookie: JSESSIONID=...node-a; Path=/bookshelf; Secure; HttpOnly
server.xml: believe the proxy’s forwarded headers, and only the proxy’s
<Valve className="org.apache.catalina.valves.RemoteIpValve"
       internalProxies="127\.0\.0\.1|::1|0:0:0:0:0:0:0:1"/>

The internalProxies expression lists the addresses whose forwarded headers are trusted; anything else is ignored. The proxy, for its part, must overwrite the forwarded headers rather than append to what the client sent, otherwise a client chooses its own address. The OmniFaces page shows what that costs: a rate limit keyed on the forwarded address was bypassed by five requests that each sent a different X-Forwarded-For. The cookie flag is the cheap protection that works either way, which is why the web.xml above also sets <secure>true</secure> itself.

2. Where a request goes. This is the load-balancing decision of Sticky Sessions. The following httpd configuration was run with the two nodes: it routes by the route suffix that Tomcat adds to the session id.

httpd configuration: a balancer with sticky sessions
# Directives of the proxy in front of the two Tomcat nodes (Apache HTTP Server 2.4).
# Modules: proxy, proxy_http, proxy_balancer, lbmethod_byrequests, slotmem_shm, headers.

# The browser talked to the proxy over HTTPS; the nodes see plain HTTP.
RequestHeader set X-Forwarded-Proto "https"
ProxyPreserveHost On

<Proxy "balancer://bookshelf">
    BalancerMember "http://127.0.0.1:8760" route=node-a
    BalancerMember "http://127.0.0.1:8761" route=node-b
    # Tomcat appends the jvmRoute of the Engine to the session id: JSESSIONID=...<id>.node-a
    ProxySet stickysession=JSESSIONID|jsessionid scolonpathdelim=On
</Proxy>

ProxyPass "/bookshelf" "balancer://bookshelf/bookshelf"
ProxyPassReverse "/bookshelf" "balancer://bookshelf/bookshelf"

3. WebSockets. f:websocket opens a long-lived connection to /jakarta.faces.push/<channel>. The proxy must pass the upgrade and keep an idle connection open longer than its own timeout. In httpd that is mod_proxy_wstunnel (a ProxyPass with a ws:// URL for the push path); in nginx it is the Upgrade and Connection headers and a raised proxy_read_timeout, whose default is 60 seconds. The nginx file below follows its documentation and was not run (no nginx was available); it also shows the open-source sticky cookie directive, which exists since nginx 1.27.3. A message sent to a channel reaches the sockets of one node only: see State That Stays on One Node and the WebSocket page.

nginx.conf: sticky cookie, forwarded headers, WebSocket upgrade
# conf.d/default.conf of the nginx container in front of two Bookshelf nodes.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream bookshelf {
    server bookshelf-a:8080;
    server bookshelf-b:8080;
    # Session affinity with a cookie that nginx sets itself (open source since nginx 1.27.3).
    sticky cookie bookshelf_node expires=1h path=/ httponly secure;
}

server {
    listen 80;

    location /bookshelf/ {
        proxy_pass http://bookshelf;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $remote_addr;      # overwrite, never append what the client sent
        proxy_set_header X-Forwarded-Proto $scheme;

        # f:websocket: pass the upgrade and keep idle sockets open longer than the 60 s default.
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 1h;
    }
}

4. Caching. A shared cache in front of Faces must not store the pages, which are per user and carry a state token. The resources served from /jakarta.faces.resource/ can be cached according to the rules of the resource handling page. Neither httpd (with mod_cache not loaded) nor nginx (without proxy_cache) caches anything in the configurations above, which is the safe default.

The cloud providers' load balancers (see the AWS, Azure and Google Cloud sections of this guide) offer cookie-based affinity with their own cookie, and the Kubernetes ingress and gateway objects have their own affinity settings; the Faces rule is the same everywhere: if a strategy needs the postback to return to the node that rendered the page, something in front of the nodes must make it so.

Where the View State Lives, and What the Cluster Must Provide

The view state is what Faces needs to restore a page at the next postback: the component tree’s changes, and with them the identity of the view-scoped beans. The state saving page explains the mechanism; for a cluster it comes down to one question with three answers, drawn in the figure.

A clustered Faces deployment, and where the view state lives

Server state saving (the default of both implementations) keeps the state in the HTTP session of the node that rendered the page and sends only a token. Client state saving sends the whole state, encrypted and signed, in the jakarta.faces.ViewState field and keeps nothing on the server. A stateless view (f:view transient="true") saves nothing and sends the word stateless. Combined with the two ways of moving a request between nodes, the strategies are:

flowchart TD A["A postback can reach a node that did not render the page?"] -->|no, the proxy is sticky and nodes never fail| S["Server state, sticky sessions only"] A -->|yes| B{"Which state does the page need?"} B -->|none: a read-only page or a search form| L["Stateless view: nothing to share"] B -->|view state| C{"Can the session be replicated?"} C -->|yes: all beans Serializable, distributable| R["Server state with session replication"] C -->|no| K["Client state with the same keys on every node"] K --> V["@ViewScoped beans still live in the session of one node"]

The rest of the page tests each box of that diagram: sticky sessions in Sticky Sessions, replication in Session Replication, the rules for beans in Serialisable Beans and Components and Changes Made in Place Are Not Replicated, client state in Client State Saving Across Nodes, and the surprise of the last box in @ViewScoped in a Cluster.

The observed behaviour of the three strategies, on the same two nodes, is the baseline for everything that follows. A page is rendered by node A and three Save postbacks run there; the next postback goes to node B with the state A produced last. The rows differ in what node B has:

Table 1. The next postback, on node B (counter: the view bean’s counter after that postback; 4 means the same bean instance)
Strategy and what B has Mojarra 4.1.17 MyFaces 4.1.4

server state, B has no session of that user

ViewExpiredException (HTTP 500 without an error page)

ViewExpiredException

server state, B has A’s replicated session

restored, counter 4 (with com.sun.faces.enableDistributable, Session Replication); without it ViewExpiredException

restored, view bean found but counter 1 (Changes Made in Place Are Not Replicated)

client state, same keys, no session

restored; a new view bean (counter 1)

restored; a new view bean (counter 1)

stateless view, no session, different keys

works

works

Sticky Sessions

A sticky (or affinity) load balancer sends every request of a session to the node that created it. The balancer needs a key: Tomcat appends the jvmRoute of its <Engine> to the session id (JSESSIONID=<id>.node-a), and httpd’s stickysession=JSESSIONID|jsessionid reads the suffix and picks the member with route=node-a. nginx’s sticky cookie sets a cookie of its own. For Tomcat the only server-side step is the attribute:

server.xml: name the node in the Engine
<Engine name="Catalina" defaultHost="localhost" jvmRoute="node-a">

Behind the httpd configuration of The Reverse Proxy in Front of the Nodes, one client made six requests and all of them reached the same node (X-Node: node-b in that run); a second batch of new clients was distributed by the balancer’s algorithm (2 and 6 of 8 in the small run). Sticky sessions are the cheapest strategy, and everything in Faces works with them: server state, @ViewScoped, the flash, the session and application beans. (Weld enforces the Serializable rule of the passivating scopes at deployment in any case; see the scopes page.) Their limits are the limits of a node:

  • A node that goes down takes its sessions with it. The next postback of each of its users reaches another node, which answers with a ViewExpiredException, and every @SessionScoped bean (the login, the shopping cart) is gone. The server-state row of the table above without a session is that situation.

  • A restart is the same as a crash unless sessions are persisted, which is why a rolling restart of a sticky cluster logs users out (Failover and Rolling Restarts).

  • Load can become uneven, because a session is bound to a node for its whole life and long-lived sessions of heavy users do not move.

  • Stickiness is a hint, not a guarantee. A proxy can lose its table, a cookie can be dropped by a browser setting, and a health check can remove a node. Code that is correct only if the request returned to the same node has a bug that appears in production only.

Use sticky sessions as the first line (they save the network traffic of replication) and add replication or client state when losing a session is not acceptable.

Session Replication

Session replication copies HTTP sessions to other nodes, so that any of them can continue. The application opts in with <distributable/> (shown in the web.xml above), and the container decides how: Tomcat’s cluster with a DeltaManager (all to all) or a BackupManager (one backup per session), WildFly’s distributable-web subsystem on Infinispan, and similar mechanisms in the other servers. The Faces side has three requirements: the session must contain only serialisable objects (Serialisable Beans and Components), changes must reach the replication mechanism (Changes Made in Place Are Not Replicated), and, on Mojarra, one context parameter must be set.

Tomcat. The cluster of the experiments is the <Cluster> element below inside the <Engine>. It uses the BackupManager, a static membership of the two nodes (multicast discovery is the default of Tomcat’s cluster, but many container networks do not carry multicast), and Tomcat’s ReplicationValve to start the replication when a request ends. Node B has the same element with the roles of the addresses swapped. On separate hosts the addresses are the hosts' own.

server.xml of node A: the cluster
<Engine name="Catalina" defaultHost="localhost" jvmRoute="node-a">
  <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster" channelSendOptions="8">
    <Manager className="org.apache.catalina.ha.session.BackupManager" notifyListenersOnReplication="true" mapSendOptions="6"/>
    <Channel className="org.apache.catalina.tribes.group.GroupChannel">
      <Membership className="org.apache.catalina.tribes.membership.StaticMembershipService">
        <LocalMember className="org.apache.catalina.tribes.membership.StaticMember" host="127.0.0.1" port="8766" uniqueId="{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}"/>
        <Member className="org.apache.catalina.tribes.membership.StaticMember" host="127.0.0.1" port="8767" uniqueId="{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2}"/>
      </Membership>
      <Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver" address="127.0.0.1" port="8766" autoBind="0" selectorTimeout="5000" maxThreads="6"/>
      <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
        <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
      </Sender>
      <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
      <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor"/>
    </Channel>
    <Valve className="org.apache.catalina.ha.tcp.ReplicationValve" filter=""/>
    <ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
  </Cluster>

Tomcat’s own checklist (every session attribute Serializable, the Cluster element, <distributable/>, a jvmRoute, a load balancer in sticky mode) matches the requirements above. Two points from its documentation matter beyond the checklist: the DeltaManager replicates to every node, which the documentation does not recommend for more than about four nodes, and replication is triggered only when the session has changed by a call of setAttribute or removeAttribute, which is the source of Changes Made in Place Are Not Replicated.

The cluster traffic is session data. The channel carries serialised sessions, and with Mojarra a serialised client-state key (Client State Saving Across Nodes). Tomcat’s EncryptInterceptor encrypts the channel messages. It was run with the key taken from the environment (${CLUSTER_KEY} resolved by the EnvironmentPropertySource of Client State Saving Across Nodes): with the same key on both nodes replication worked as before; with different keys node B logged SEVERE ... EncryptInterceptor.messageReceived Failed to decrypt message with an AEADBadTagException, and node B did not get the sessions (the ViewExpiredException row again). The interceptor goes before the TcpFailureDetector in the channel:

server.xml: encrypt the cluster messages
<Interceptor className="org.apache.catalina.tribes.group.interceptors.EncryptInterceptor"
             encryptionKey="${CLUSTER_KEY}"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>

Mojarra needs enableDistributable. Mojarra documents com.sun.faces.enableDistributable as the switch that tells it the application is distributed, so that it sets a session attribute again after every change to it, which is what triggers session replication; the default is false because the extra writes cost time. The experiment confirms the need: with <distributable/> and the Tomcat cluster but without the parameter, node B answered the postback of a replicated session with a ViewExpiredException, on server state saving. With the parameter in web.xml it restored the view and the view bean counter was 4. MyFaces' configuration reference lists no parameter with this effect, and its server state restored on node B in both runs (its limit is Changes Made in Place Are Not Replicated).

web.xml: tell Mojarra that the application is distributed (ignored by MyFaces as far as its reference shows)
<context-param>
    <param-name>com.sun.faces.enableDistributable</param-name>
    <param-value>true</param-value>
</context-param>

WildFly and the others. WildFly’s guide says a web application indicates its intention with <distributable/>, that the default session management uses SESSION granularity (all attributes in one cache entry) and that ATTRIBUTE granularity replicates only the attributes that a request added, modified, removed or mutated; the profile with the clustering subsystems is standalone-ha.xml. A WildFly, Payara or Open Liberty cluster was not run for this page, and how WildFly detects a change inside a CDI bean was not tested; read the server’s guide for the detection rules before trusting Changes Made in Place Are Not Replicated to carry over.

Quarkus has no session replication. The MyFaces extension documentation states that Quarkus does not support session replication, passivation or clustering (see State Saving, Sessions and Restarts on the Quarkus page, which was run on one instance only; no Quarkus cluster was run for this page). Several Quarkus instances therefore have only the two other answers of the figure: sticky sessions with server state (a node that is lost takes its sessions and every open form with it), or client state with the same encryption keys on every instance (Client State Saving Across Nodes), which survives the loss of a node but not the loss of the view-scoped beans (@ViewScoped in a Cluster). The same reasoning applies to any runtime whose sessions are not replicated; the Tomcat cluster of this page is the case where replication is available. On Spring Boot with JoinFaces (Sessions and Cookies) a clustered deployment was not run either, so the rules of this page are the starting point and have to be tested there.

Fail-over works. With the Tomcat cluster above (Mojarra, server state), node A rendered a page and ran two postbacks (counter 2); node A was stopped; the next postback, sent to node B with A’s session id, returned counter 3. Behind httpd with the sticky configuration, the same client was routed to node B by the balancer once A was down, and kept its .node-a cookie. No cookie rewrite was needed for that (Tomcat’s JvmRouteBinderValve exists to rewrite it when the balancer relies on the suffix).

Serialisable Beans and Components

Session replication, session persistence across a restart and CDI passivation have the same requirement: whatever is reachable from the session must survive ObjectOutputStream and ObjectInputStream. In a Faces application the session reaches further than the @SessionScoped beans:

  • every bean of a passivating scope: @SessionScoped, @ConversationScoped, @FlowScoped, @ClientWindowScoped and jakarta.faces.view.ViewScoped (the view scope keeps its beans in a map in the session). Weld checks this at deployment and refuses the application with WELD-000072 when such a bean is not Serializable (scopes page, serialisation rules);

  • the view state with server state saving, which is stored in the session as well: the values of component attributes, the StateHolder objects, anything put in UIViewRoot.getAttributes() (state saving page, what must be serialisable);

  • the objects those beans hold: a Book entity graph, a Collator, a connection, an HttpClient.

The rule that makes a bean cheap and safe to move is to keep in a passivating scope only values: identifiers, strings, numbers, small immutable records. The bean below keeps the ISBNs of the reading list rather than Book objects (a graph of entities would be copied with the session at every replication) and marks the rebuildable helper transient:

ReadingList.java: a session bean that is safe to move
package org.example.bookshelf.web;

import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Named;
import java.io.Serializable;
import java.text.Collator;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/**
 * The visitor's reading list. It lives in the HTTP session, so the container may serialise it to
 * move it to another node: it keeps ISBNs (a graph of {@code Book} objects would be copied too)
 * and marks the rebuildable helper {@code transient}.
 */
@Named("readingList")
@SessionScoped
public class ReadingList implements Serializable {

    private static final long serialVersionUID = 1L;

    private final List<String> isbns = new ArrayList<>();

    /** Not serialised; rebuilt lazily after the session has moved. */
    private transient Collator collator;

    public List<String> getIsbns() {
        return isbns;
    }

    public void add(String isbn) {
        if (!isbns.contains(isbn)) {
            isbns.add(isbn);
            isbns.sort(collator());
        }
    }

    /** Adds a fixed ISBN; used by the demo page. */
    public void addNext() {
        add("978-0-00-" + String.format("%06d", isbns.size() + 1));
    }

    public int getSize() {
        return isbns.size();
    }

    private Collator collator() {
        if (collator == null) {
            collator = Collator.getInstance(Locale.ROOT);
        }
        return collator;
    }
}

A unit test finds the mistakes before the cluster does. It copies the bean through serialisation, which is what a replication does, and it checks that a bean that holds a non-serialisable field is rejected. It was run with JUnit Jupiter 6.0.3 (two tests, both passed):

SessionStateSerialisationTest.java
package org.example.bookshelf.web;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.NotSerializableException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import org.junit.jupiter.api.Test;

/** What a session replication does to the beans of a session, without a container. */
class SessionStateSerialisationTest {

    @SuppressWarnings("unchecked")
    private static <T> T copyThroughSerialisation(T bean) throws IOException, ClassNotFoundException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
            out.writeObject(bean);
        }
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            return (T) in.readObject();
        }
    }

    @Test
    void readingListSurvivesTheMoveToAnotherNode() throws Exception {
        ReadingList before = new ReadingList();
        before.add("978-0-00-000002");
        before.add("978-0-00-000001");

        ReadingList after = copyThroughSerialisation(before);

        assertEquals(before.getIsbns(), after.getIsbns());
        after.add("978-0-00-000003");          // the transient collator is rebuilt on demand
        assertEquals(3, after.getSize());
    }

    @Test
    void aBeanThatHoldsAConnectionIsRejected() {
        assertThrows(NotSerializableException.class,
                () -> copyThroughSerialisation(new DraftBean.Leaky()));
    }
}

The unit test finds problems in beans, not in the component state. That needs a running application: jakarta.faces.SERIALIZE_SERVER_STATE set to true makes Faces serialise the state when it saves it, so that a non-serialisable value fails on a developer’s machine instead of in the cluster (it is part of the state saving page). In the cluster the same mistake looked like this. An action put an object of a class that implements Serializable but holds an Object field into the view root’s attributes (the Leaky class of the scratch DraftBean):

DraftBean.java (excerpt): a Serializable class with a field that is not
    /** Declared Serializable, but holds a field that is not. */
    public static class Leaky implements Serializable {
        private final Object connection = new Object();
        public Object getConnection() { return connection; }
    }

    public void poison2() {
        FacesContext.getCurrentInstance().getViewRoot().getAttributes().put("junk", new Leaky());
    }

What each configuration did with it:

Table 2. A non-serialisable value in the view state
Configuration Mojarra 4.1.17 MyFaces 4.1.4

server state, replicated session

the action and the next postback on node A worked; Tomcat logged SEVERE ... AbstractReplicatedMap.replicate Unable to diff object. Will replicate the entire object instead. with a NotSerializableException: java.lang.Object; node B answered the postback with a ViewExpiredException

the same on node A; node B answered with a ViewExpiredException (the Tomcat message was not captured for this run)

client state

the request that put the value failed at once with HTTP 500 and NotSerializableException: java.lang.Object

HTTP 500 on the same request

The dangerous row is the first. Nothing failed for the user on node A, the only trace is a log line of the container, and the damage is discovered when a node is lost. Alert on NotSerializableException and on replication errors in the logs of a clustered application (Logging), and keep the SERIALIZE_SERVER_STATE run in the test environment.

Changes Made in Place Are Not Replicated

Serialisability is the first rule of replication, the second is when the container copies the session. Tomcat’s documentation says that data is replicated only if the session has changed by a call of setAttribute or removeAttribute. A mutation of an object that is already in the session, such as isbns.add(...) on the list inside ReadingList, is not a session change. In a Faces application that matters more than it seems, because the session holds the CDI session beans, the view maps of the @ViewScoped beans and the saved view states, and none of them are set again by your code. Weld’s bean store sets the attribute when it adds the bean instance; in the bytecode of Weld 6.0.4’s AttributeBeanStore the setAttribute calls are in the methods that add a bean and that re-attach the store, not in the access path.

The experiment: node A renders reading.xhtml (the page creates the @SessionScoped ReadingList with size 0) and three Add postbacks run on A (size 3). Then a request with the same session id goes to node B, then to A again:

Table 3. The reading list after three additions on node A, seen by a request on node B (after a pause for the replication)
Variant Mojarra 4.1.17 MyFaces 4.1.4

ReadingList: the bean mutates its own list

B sees 0; A, asked again after B had served the session, also 0 (the three additions are lost)

B sees 0; A also 0

ReadingListStore: one immutable list replaced with setAttribute

B sees 3, A sees 3

B sees 3, A sees 3

ReadingList and the SessionReplicationFilter below

B sees 3, A sees 3

B sees 3, A sees 3

The second row is the cure to use when the state is small: hold it as an immutable value in a session attribute, and change it only with setAttribute. The class below is the one that was run; it injects the HttpSession (a built-in CDI bean in a servlet container):

ReadingListStore.java: the replication-safe version
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.List;

/**
 * The reading list kept as an immutable value in one HTTP session attribute. Every change replaces
 * the attribute with setAttribute, which is the call that makes the container replicate it.
 */
@Named("readingStore")
@RequestScoped
public class ReadingListStore {

    static final String ATTRIBUTE = "bookshelf.readingList";

    @Inject
    private HttpSession session;

    @SuppressWarnings("unchecked")
    public List<String> getIsbns() {
        List<String> current = (List<String>) session.getAttribute(ATTRIBUTE);
        return current == null ? List.of() : current;
    }

    public int getSize() {
        return getIsbns().size();
    }

    public void addNext() {
        List<String> next = new ArrayList<>(getIsbns());
        next.add("978-0-00-" + String.format("%06d", next.size() + 1));
        session.setAttribute(ATTRIBUTE, List.copyOf(next));   // a new value: this is what gets replicated
    }
}

The third row is the cure when the beans cannot be changed (view-scoped beans, third-party beans) and the container replicates only on setAttribute: set every attribute again when the request is over. That is what Mojarra does for its own attributes with enableDistributable; the filter does it for all of them. It costs one serialisation of the whole session per request, so use it where nothing better exists, and measure. With it, MyFaces' view-scoped counter went from 1 to 4 in the table of @ViewScoped in a Cluster:

SessionReplicationFilter.java
package org.example.bookshelf.web;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Collections;

/**
 * Re-sets every session attribute after the request, so that a container that replicates only on
 * setAttribute (Tomcat) also sends attributes that were changed in place. It costs one
 * serialisation of the whole session per request: use it only where nothing better exists.
 */
@WebFilter("/*")
public class SessionReplicationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } finally {
            HttpSession session = ((HttpServletRequest) request).getSession(false);
            if (session != null) {
                for (String name : Collections.list(session.getAttributeNames())) {
                    session.setAttribute(name, session.getAttribute(name));
                }
            }
        }
    }
}

The filter belongs to Tomcat-style replication. Other containers decide differently (WildFly’s guide describes mutation detection for ATTRIBUTE granularity), so test the behaviour on the server you deploy to instead of assuming either. The question to ask of any container is the one this experiment answers: change an object in place on node A, ask node B for it, and look.

Client State Saving Across Nodes

Client state saving removes the view state from the server: the state is in the page, so any node can restore it, provided it can decrypt it. The state saving page describes the keys; the cluster adds one rule: the same keys on every node and across restarts, and the keys are never in the WAR. The two implementations read them differently, and Tomcat can pass an environment variable into both:

META-INF/context.xml: the keys come from the environment
<Context>
  <!-- Mojarra reads this entry as the client state key; the value comes from the environment. -->
  <Environment name="faces/ClientSideSecretKey" value="${FACES_CLIENT_SECRET}"
               type="java.lang.String" override="false"/>
  <!-- MyFaces reads these two context parameters. -->
  <Parameter name="org.apache.myfaces.SECRET" value="${MYFACES_SECRET}" override="false"/>
  <Parameter name="org.apache.myfaces.MAC_SECRET" value="${MYFACES_MAC_SECRET}" override="false"/>
</Context>

The ${...} placeholders in context.xml are replaced by Tomcat only when it is told to read the environment:

Starting Tomcat so that context.xml sees the environment
export CATALINA_OPTS="-Dorg.apache.tomcat.util.digester.PROPERTY_SOURCE=org.apache.tomcat.util.digester.EnvironmentPropertySource"
FACES_CLIENT_SECRET="$(cat /run/secrets/faces-client-secret)" \
MYFACES_SECRET="$(cat /run/secrets/myfaces-secret)" \
MYFACES_MAC_SECRET="$(cat /run/secrets/myfaces-mac-secret)" \
  catalina.sh run

Without the property source, the literal text ${FACES_CLIENT_SECRET} becomes the key and both implementations fail closed (see the state saving page). The same documentation lists ServiceBindingPropertySource, which reads values from the files of a Kubernetes service binding; it was not run. The server must be restarted in a rolling fashion with the same key: a changed key invalidates every page already in a browser (a ViewExpiredException on the next postback), so rotate keys at a quiet time, with the old pages' expiry in mind.

The experiment used the same context file on both nodes with the same keys, and then with different keys on the two nodes. The rows are the next postback on node B; "replicated session" means B received A’s session id with A’s replicated session:

Table 4. Client state saving on node B, for the page node A rendered
What node B has Mojarra 4.1.17 MyFaces 4.1.4

same keys, no session

restored, but a new view bean (counter 1)

restored, new view bean (counter 1)

same keys, replicated session

restored, the view bean of A (counter 4)

restored, the view bean of A but stale (counter 1; @ViewScoped in a Cluster)

different keys, no session

ViewExpiredException

ViewExpiredException

different keys, replicated session

restored (counter 4)

ViewExpiredException

The last row, on Mojarra, surprised. The explanation is in Mojarra’s ByteArrayGuard: when a session exists, it looks for a secret key in the session attribute com.sun.faces.SK, and if there is none it stores its configured key there; the key in the session then wins. A session replicated from node A therefore carries node A’s key to node B and node B decrypts with it, whatever it was configured with. The source (Mojarra 4.1 branch, matching the bytecode of 4.1.17) is plain:

Mojarra’s ByteArrayGuard.getSecretKey (paraphrased)
key = the configured key
if a session exists:
    key = session attribute "com.sun.faces.SK"
    if key is null: store the configured key in the session; key = the configured key
return key

The consequences are practical:

  • Different keys on the nodes are masked, not fixed, when sessions are replicated. Existing sessions keep working and new sessions or sessionless requests fail, which is the most confusing failure a cluster can offer. Configure the same key everywhere and test with a session-less client (no cookie), which uses the configured key.

  • The key is in the session, so it is in the replication traffic (encrypt the channel, Session Replication) and in any persisted session store. Treat session storage as secret material.

  • Rotating the configured key does not affect sessions that already hold the old one, on Mojarra, until they end (this follows from the source above and was not run); on MyFaces the configured SECRET and MAC_SECRET are the only keys, and the last row of the table shows that a replicated session does not help there.

@ViewScoped in a Cluster

@ViewScoped beans are the main reason server state saving is hard to leave behind. A view-scoped bean is not part of the view state, whatever the state saving method (the state saving page checks this): the beans live in the session, in a map per view, and the view state only holds the identifier of that map. The table above shows the consequence for client state: with the same keys and no session, node B restored the page and created a new bean, and the user lost what the previous bean held (saves back to 0 and counting).

Table 5. The view bean on node B (counter after the postback on B, view bean id of A in brackets)
Configuration Mojarra 4.1.17 MyFaces 4.1.4

server state, replicated, default settings

expired without enableDistributable; with it counter 4 (bean of A)

counter 1 (bean of A, but its counter is the one from when the bean was created)

server state, replicated, with the SessionReplicationFilter

counter 4

counter 4

client state, same keys, replicated session

counter 4

counter 1 (stale)

client state, same keys, no session

a new bean, counter 1

a new bean, counter 1

So in a cluster, an application that wants to use client state saving to avoid replicating the session still needs one of three things for its view-scoped beans: sticky routing, replication of the session (with the in-place rule of Changes Made in Place Are Not Replicated), or no @ViewScoped beans in the pages that can land on any node. The third is the stateless view of Where the View State Lives, and What the Cluster Must Provide: request-scoped beans only, the whole of the view state is the word stateless, and the postback worked on node B without a session, with different keys, on both implementations (a search form with an h:inputText, a button and a ui:repeat, state stateless). The cost is that the form must be written to rebuild everything from the request, and it is the right choice for the read-mostly pages of Bookshelf: the catalogue, the search, the book detail.

The view scope also bounds the session: Mojarra keeps up to com.sun.faces.numberOfActiveViewMaps view maps per session (25 by default; the least recently used is destroyed), MyFaces the number of views in the session. Each map is replicated with the session, so the limits are a cluster setting as well as a memory setting (scopes page, view scope limits). The OmniFaces view scope has the same passivation rule and an unload-based lifecycle (OmniFaces page).

State That Stays on One Node

Not everything Faces keeps lives in the session. These are the things that stay on one node, and what to do about each.

The flash. The flash carries a message or an object across a redirect (flash scope), which in a cluster means a redirect that lands on another node. A page posted a form to node A, which stored a note in the flash and redirected; the redirect target was then requested from three places:

Table 6. Does the flash note reach the page after the redirect?
The GET after the redirect goes to Mojarra 4.1.17 MyFaces 4.1.4

node A

yes

yes

node B with A’s replicated session

no

yes

node B, no session

no

no

Mojarra keeps the flash in an application-wide store (its documentation describes numberOfConcurrentFlashUsers as sizing that store) and sends a cookie to find the entry, so the entry stays on the node and replication cannot move it; MyFaces keeps it in the session. The cure on Mojarra is sticky routing at least for the redirect, or avoiding the flash for anything that matters: a confirmation can be carried in a query parameter that the target view reads with f:viewParam, or stored with the entity.

Server push. PushContext.send sends to the WebSockets connected to this JVM. A cluster needs an event bus between the nodes, and each node calls send locally. The bean below separates the two steps; the adapter that listens to the bus (a Jakarta Messaging topic, a Redis channel, a database notification) calls deliver on every node, the sender’s included. The three classes compile against Faces 4.1; the bus itself was not run (the messaging pages of this guide cover the options, for example the messaging models and patterns page):

ClusterBus.java: the port to whatever connects the nodes
package org.example.bookshelf.web;

/**
 * The port to whatever carries events between the nodes (a Jakarta Messaging topic, a Redis
 * channel, a database notification). Its adapter calls {@link ReviewBroadcaster#deliver} on
 * every node, the sender's node included.
 */
public interface ClusterBus {

    void publish(ReviewMessage message);
}
ReviewMessage.java: strings and numbers only
package org.example.bookshelf.web;

import java.io.Serializable;

/** What travels over the cluster bus and then over the WebSocket: strings only. */
public record ReviewMessage(String isbn, String reviewer, int stars) implements Serializable {
}
ReviewBroadcaster.java: publish to the bus, push locally
package org.example.bookshelf.web;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.faces.push.Push;
import jakarta.faces.push.PushContext;
import jakarta.inject.Inject;
import java.util.Map;

@ApplicationScoped
public class ReviewBroadcaster {

    @Inject
    @Push(channel = "reviews")
    private PushContext reviews;

    @Inject
    private ClusterBus bus;

    /** Called by the review service after the review is stored; it does not push by itself. */
    public void reviewPosted(ReviewMessage message) {
        bus.publish(message);
    }

    /** Called by the bus adapter on EVERY node: pushes to the sockets of this JVM only. */
    public void deliver(ReviewMessage message) {
        reviews.send(Map.of(
                "isbn", message.isbn(),
                "reviewer", message.reviewer(),
                "stars", message.stars()));
    }
}

Three more things behave as node-local by design; these are reasoning from their scopes and were not run in the cluster:

  • @ApplicationScoped beans and static fields have one instance per JVM: a cache, a counter or a "currently open reviews" set is per node. Use a shared store (a database, a distributed cache) for anything that must be the same everywhere.

  • Timers and scheduled work run on every node. A job that must run once needs a lock or a leader election in the shared store.

  • Uploaded files are written to the node that received them (the file upload page covers the Part and where to store it); put them in an object store or shared volume, not in a directory of the node.

Failover and Rolling Restarts

A restart is a planned node failure, and the strategies of the previous sections decide what the users see:

Strategy What a node stop does to the user

server state, sticky only

the next postback is a ViewExpiredException and the session beans are gone: the user is logged out. Reasonable for a read-mostly application with an error-page that sends the user back to the page

server state, replicated session

the next postback is served by the other node (observed: counter 2 on A, 3 on B after A stopped). Nothing is lost that was replicated

client state, same keys, no replication

the page is restored; a @ViewScoped bean is a new instance and the session beans are gone

stateless views

nothing to lose for those pages

For a rolling restart, take the node out of the rotation first so that no new sessions start there (fail its readiness probe, Monitoring, or drain it in the balancer), wait until its users have moved or the sessions have expired, stop it, start the new version and let it rejoin. With replication, a new version of the application on one node must still be able to deserialise sessions written by the old one: a bean class that changed incompatibly (a new required field, a changed serialVersionUID) fails to deserialise and the affected users get a ViewExpiredException or a lost session. Declaring serialVersionUID and adding fields instead of changing them is the discipline that makes this survivable; the ReadingList above declares one.

Two Faces details help during a rollout. Resources have version parameters in their URLs (see the resource versioning), so a browser that kept an old page can still fetch the resources that page refers to while both versions are deployed, and the error page for ViewExpiredException is what a user meets when the state is gone: write it as a way back, not as a dead end (ViewExpiredException handling).

Monitoring

Four signals tell whether a Faces cluster is healthy: whether each node can take traffic, which node served a request, what the session layer is doing, and how many users meet an expired view.

Readiness. A load balancer should ask a node a cheap question: no session, no Faces lifecycle and no database call, or the probe itself creates sessions and load. A servlet answers it. It sends Cache-Control: no-store so that no cache keeps an old answer:

ReadinessServlet.java
package org.example.bookshelf.web;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/** A cheap readiness probe for a load balancer: no session, no Faces, no database. */
@WebServlet("/health/ready")
public class ReadinessServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("text/plain");
        response.setHeader("Cache-Control", "no-store");
        response.getWriter().write("ready\n");
    }
}

The servlet answered 200 ready with an X-Node header on both nodes and set no cookie. A real probe checks more (the database, a downstream service) and returns 503 when the node should leave the rotation; keep it cheap, and keep a liveness probe, which asks only whether the process is stuck, separate from readiness (the Kubernetes page on health probes explains the difference).

Which node served the request. A filter adds the node name to every response and the access log can then show it. The variable comes from the environment (NODE_NAME in the Compose and Kubernetes files above):

NodeHeaderFilter.java
package org.example.bookshelf.web;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/** Adds the name of the node that served the response, to see where a request landed. */
@WebFilter("/*")
public class NodeHeaderFilter implements Filter {

    private final String node = System.getenv().getOrDefault("NODE_NAME", "unnamed");

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        ((HttpServletResponse) response).setHeader("X-Node", node);
        chain.doFilter(request, response);
    }
}

A Tomcat access log valve with the pattern below wrote lines with the node name and the processing time in microseconds (%D; the unit is microseconds in Tomcat 11 and the first request on a cold node took about 95 ms). The session id is deliberately not in the pattern:

server.xml: an access log with the node name
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
       prefix="access" suffix=".log"
       pattern="%h %t &quot;%r&quot; %s %b %D %{X-Node}o"/>
access.2026-10-08.log (node A, node B)
0:0:0:0:0:0:0:1 [08/Oct/2026:18:37:47 +0200] "GET /bookshelf/draft.xhtml HTTP/1.1" 200 1422 95632 node-a
0:0:0:0:0:0:0:1 [08/Oct/2026:18:37:46 +0200] "GET /bookshelf/draft.xhtml HTTP/1.1" 200 1421 105162 node-b

The session layer. Tomcat exposes the session manager and the cluster through JMX. The program below attaches to a running node, starts the local management agent and prints the attributes that matter; run on node B after three new sessions, it printed activeSessions = 3, maxActive = 3, rejectedSessions = 0, expiredSessions = 0 and the class org.apache.catalina.ha.session.BackupManager for Catalina:type=Manager,host=localhost,context=/bookshelf, plus the ReplicationValve (nrOfSendRequests, totalSendTime) and the http-nio-8761 thread pool (currentThreadsBusy, currentThreadCount). A monitoring agent or a Prometheus JMX exporter reads the same MBeans; the interesting alarms are sessions that grow without bound and rejectedSessions above zero.

Mx.java: read the session and replication MBeans of a local Tomcat
import com.sun.tools.attach.VirtualMachine;
import javax.management.*;
import javax.management.remote.*;
import java.util.*;
public class Mx {
    public static void main(String[] a) throws Exception {
        VirtualMachine vm = VirtualMachine.attach(a[0]);
        String url = vm.startLocalManagementAgent();
        try (JMXConnector c = JMXConnectorFactory.connect(new JMXServiceURL(url))) {
            MBeanServerConnection m = c.getMBeanServerConnection();
            for (String pat : new String[]{"Catalina:type=Manager,host=*,context=*", "Catalina:type=Cluster*", "Catalina:type=Valve,*,name=ReplicationValve", "Catalina:type=ThreadPool,name=*"}) {
                for (ObjectName n : new TreeSet<>(m.queryNames(new ObjectName(pat), null))) {
                    System.out.println(n);
                    MBeanInfo info = m.getMBeanInfo(n);
                    StringBuilder sb = new StringBuilder();
                    for (MBeanAttributeInfo at : info.getAttributes()) {
                        if (!at.isReadable()) continue;
                        String nm = at.getName();
                        if (nm.matches("activeSessions|maxActive|maxActiveSessions|sessionCounter|expiredSessions|rejectedSessions|sessionMaxAliveTime|sessionAverageAliveTime|processingTime|totalSendTime|nrOfSendRequests|nrOfFilterRequests|currentThreadsBusy|currentThreadCount|className|name|distributable|stateName|nrOfCrossContextSessions|sendAllSessionsOnStartup|sessionReplaceCounter|receivedTime|sentTime|processingTimeAll")) {
                            try { sb.append("   ").append(nm).append(" = ").append(m.getAttribute(n, nm)).append('\n'); } catch (Exception e) { sb.append("   ").append(nm).append(" (error)\n"); }
                        }
                    }
                    System.out.print(sb);
                }
            }
        }
        vm.detach();
    }
}

Application signals. The Faces-specific number is the rate of ViewExpiredException. A slow rise after a deployment means pages from the old version meeting the new one, and a sustained rate on a replicated cluster means replication is failing (Serialisable Beans and Components) or the keys differ (Client State Saving Across Nodes). Count the exception in the handler that renders the error page (the exception handling page shows where) and export it with the metrics library of the stack, as in the Spring Boot metrics and observability page or the Kubernetes observability page.

Logging

Both implementations log through java.util.logging, which every server routes into its own log. Mojarra’s loggers are named jakarta.enterprise.resource.webcontainer.faces.<area> (the areas in 4.1.17 include application, application.view, config, context, flash, lifecycle, clientwindow, renderkit, resource, timing and the Facelets ones); MyFaces' loggers are named after its classes under org.apache.myfaces. In production the defaults are what you want, and the logger of one area is raised when a problem needs it. The Mojarra lifecycle logger at FINE in Tomcat’s logging.properties:

conf/logging.properties: lifecycle logging for a diagnosis
jakarta.enterprise.resource.webcontainer.faces.lifecycle.level = FINE
java.util.logging.ConsoleHandler.level = FINE

printed the phases of each request:

Mojarra lifecycle log (excerpt), logger jakarta.enterprise.resource.webcontainer.faces.lifecycle
FINE [http-nio-8760-exec-1] com.sun.faces.lifecycle.RestoreViewPhase.execute Entering RestoreViewPhase
FINE [http-nio-8760-exec-1] com.sun.faces.lifecycle.RestoreViewPhase.execute New request: creating a view for /draft.xhtml
FINE [http-nio-8760-exec-1] com.sun.faces.lifecycle.RenderResponsePhase.execute About to render view /draft.xhtml

What to log in a cluster, and what not:

  • To the console, in containers. The official Tomcat image logs to the console and the Payara image is configured not to write the server log to a file; the platform collects standard output (the Docker logging page has the options).

  • The node name in every line or in the access log, from NODE_NAME, so that a request can be followed across nodes.

  • The replication errors of the container (for Tomcat the SEVERE lines of AbstractReplicatedMap and EncryptInterceptor quoted above) and every NotSerializableException.

  • Never the view state, the session id, the keys or the contents of a replicated attribute. The client state is an encrypted value that is still a credential-adjacent secret, and the session id is a credential.

  • Keep Mojarra’s lifecycle and timing loggers and MyFaces' org.apache.myfaces at FINE out of production: they log every request.

Mojarra and MyFaces Compared

Everything in this table was observed on the two Tomcat 11.0.26 nodes with Weld 6.0.4 unless it says otherwise.

Table 7. Deployment differences between the implementations
Aspect Mojarra 4.1.17 MyFaces 4.1.4

Server state moves with the replicated session

only with com.sun.faces.enableDistributable set to true; without it ViewExpiredException on node B

yes, with no setting

View bean after the move (three saves on A)

current (counter 4) with the parameter

stale: the copy made when the bean was created (counter 1); current with the SessionReplicationFilter

Client state key

JNDI entry faces/ClientSideSecretKey, and the key stored in the session (com.sun.faces.SK) once a session exists

context parameters org.apache.myfaces.SECRET and MAC_SECRET, never in the session

Different keys on two nodes, replicated session

works for sessions created on the node whose key was stored; new sessions and sessionless requests fail

ViewExpiredException

Flash across nodes (redirect to the other node)

lost even with a replicated session (application-wide store)

kept with a replicated session

Non-serialisable value in the view state

node-local success, ViewExpiredException on the other node, a container log line

the same

Stateless view on another node, no session

works

works

Text of the exception

viewId:/draft.xhtml - View /draft.xhtml could not be restored.

View "/draft.xhtml" could not be restored.

What Changed per Version

Version Change

2.0 to 2.2

Stateless views (f:view transient="true") and jakarta.faces.SERIALIZE_SERVER_STATE (then javax.faces) arrive in 2.2, which is when "no state, no cluster problem" becomes a choice. Mojarra’s com.sun.faces.enableDistributable is documented since 2.2.7.

3.0

The javax to jakarta rename. A WAR needs a web.xml in the Jakarta namespace (Servlet 5.0) if it has one, the context parameters are named jakarta.faces.*, and the server must be a Jakarta EE 9 (Tomcat 10.0) or later server. Mojarra’s client key entry is still jsf/ClientSideSecretKey.

4.0

Servlet 6.0 and Jakarta EE 10 (Tomcat 10.1, Weld 5). Mojarra reads the client state key from java:comp/env/faces/ClientSideSecretKey, so a configuration from 3.0 that still sets jsf/ClientSideSecretKey silently produces a random key per node and per restart. Mojarra 4.0.7 adds com.sun.faces.numberOfConcurrentFlashUsers, the correctly spelled name of the old numberOfConcerrentFlashUsers.

4.1

Servlet 6.1 and Jakarta EE 11 (Tomcat 11, Weld 6.0, web-app_6_1.xsd). Full state saving is deprecated for removal, which removes one reason to keep a heavy state, and MyFaces 4.1.4 changes the default autocomplete of the state field to one-time-code.

5.0 (upcoming)

Faces 5.0 is a milestone release; this page does not describe it. Check the release notes before relying on the parameters above.

Where the Books Are Outdated

The eight books say little about operating a Faces application, and what they say belongs to the application server of their day.

  • Old containers. The Manning book developed its examples on Tomcat 5.0, and the Complete Reference assumes a Servlet 2.5 container such as Tomcat 5.x. Tomcat 11 implements Servlet 6.1 in the jakarta namespace, runs on Java 17 or later, and a WAR built for those books' servers does not run on it unchanged.

  • Build and deploy with Ant. The Wiley book builds its sample application with an Ant build.xml and packages it as a WAR. The build is Maven now (Packaging the WAR), the WAR is built once and promoted through environments, and the server is usually a container image (Docker Images for Tomcat, WildFly and Payara).

  • Client state as the answer to clusters. Pro JSF and Ajax says client-side saving avoids the memory cost of server state, has an advantage in clustered environments and costs response time, and the Manning book argues that client state survives a server failure. Both are true only with conditions that neither book states: the nodes must share fixed keys, the state must be encrypted and signed, and view-scoped and session beans still need a home (Client State Saving Across Nodes, @ViewScoped in a Cluster). None of the books mentions the Mojarra key setting.

  • Serialisation of the saved view. The Complete Reference explains that in JSF 1.1 the saved view class was not serialisable, which broke session failover in clusters, and that JSF 1.2 redesigned the StateManager API to fix it. That API is removed in Faces 4.0, state is saved as a delta by partial state saving, and the rules for what must be serialisable are those of Serialisable Beans and Components and Changes Made in Place Are Not Replicated.

  • PROJECT_STAGE copied from samples. The Complete Reference and the Packt cookbook set javax.faces.PROJECT_STAGE to Development in their web.xml examples. That is right for a tutorial and wrong for a server: the name is jakarta.faces.PROJECT_STAGE since 3.0 and a deployment must run in Production (The Production Stage on a Server).

  • The old parameter names. Every context parameter in the books is javax.faces.*; the names are jakarta.faces.* since Faces 3.0.

  • No containers, no proxies, no probes. None of the books has a Dockerfile, a readiness probe or a reverse proxy configuration (Docker Images for Tomcat, WildFly and Payara, The Reverse Proxy in Front of the Nodes, Monitoring).

Common Mistakes

  • Server state, two nodes, no stickiness and no replication. Every second postback is a ViewExpiredException. Test with two nodes before launch, not with one.

  • <distributable/> without testing replication. The tag is a promise. Without com.sun.faces.enableDistributable (Mojarra) the state did not arrive on the other node although the application was marked distributable.

  • A Serializable bean that holds a non-serialisable field. Works on one node, fails in the logs of the container and on the other node. Run the unit test of Serialisable Beans and Components and SERIALIZE_SERVER_STATE=true.

  • Changing a session or view bean in place and trusting replication. The three additions of the reading list were gone after the move. Replace immutable values with setAttribute, or set the attributes again.

  • Different client-state keys on different nodes. Masked on Mojarra while sessions are replicated, a ViewExpiredException for new sessions. Same key everywhere, from a secret store, tested without a cookie.

  • Client state saving as a way not to replicate the session. The @ViewScoped beans are still in the session.

  • Keys, passwords or cluster keys in the WAR, in the Dockerfile or in an image layer. Use the environment or mounted secrets.

  • A flash-based confirmation behind a round-robin balancer. Works on a developer’s machine and on Mojarra loses the message on the other node.

  • A shared cache in front of Faces pages. Pages carry per-user state tokens.

  • Forwarded headers trusted from anyone. Trust them only from the proxy, and make the proxy overwrite them.

  • The container’s default error page in production. A stack trace and a server version for every failure.

  • Publishing the admin port of a container image. Payara’s image has a well-known administration password in its Dockerfile.

  • Running Tomcat as root in the container. The official image does; add a user.

  • Development stage, short refresh period or ui:debug left on. See the configuration page.

  • A rolling restart that rejects old sessions. Changed bean classes without a stable serialVersionUID or a plan for old state.

  • Probes that create sessions. A readiness probe through the Faces servlet makes a session for every check.

The general-purpose guides: Docker, Compose, Kubernetes, backend development and the load balancer pages of the AWS, Azure and Google Cloud sections.

References