Flash Scope and Post-Redirect-Get

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) with Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, Jakarta Servlet 6.1 and Java 17 or later (the examples were compiled with --release 21). The flash exists since JSF 2.0, so everything below also applies to Faces 4.0, but only 4.1 was run. The code in this page was deployed as an exploded Bookshelf WAR on Tomcat 11.0.26 with Weld 6.0.4.Final, once with Mojarra and once with MyFaces, and driven with curl (every request, redirect and cookie shown was observed on 2026-10-08, by following the Location header by hand). Claims that were not run are marked as such.

A browser form is submitted with an HTTP POST. If the response to that POST is the confirmation page itself, the browser’s address bar still shows the POST target, and pressing reload, or the back and forward buttons, offers to send the form again. The cure is the Post/Redirect/Get pattern (PRG): after the POST the server answers with a redirect, and the browser fetches the result with a GET. A redirect creates a new request, and a new request has a new FacesContext, new request-scoped beans and an empty message list. Whatever the confirmation page needs from the POST has to travel across the redirect, and the flash scope is Faces' answer to that.

The Problem: Rendering a Result from a POST

The Bookshelf review form posts a review. The simplest action saves it, queues a confirmation message and returns null, which re-renders the same view in the POST response:

An action that renders the confirmation from the POST response
    /** Without Post/Redirect/Get: the POST response itself shows the confirmation. */
    public String submitNaive() {
        reviews.save(new Review(bookTitle, rating, comment));
        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Thanks for your review.", null));
        return null;
    }

It works, and it has two defects that PRG removes:

  • Resubmission. The browser’s current page is the result of a POST. Reload repeats it. Sending the same valid form twice through this action stored two reviews: the page showing Reviews stored so far read 1, then 2 (Mojarra and MyFaces alike). With a real database this is a duplicate order, a second payment or a second e-mail.

  • Wrong URL. The address bar shows the page that received the POST, not the page that shows the result, so the result cannot be bookmarked, and the back button leads to a "confirm form resubmission" dialog.

A redirect after a successful POST gives a GET for the result page: reload now repeats a harmless read. In lifecycle terms (see the phases) the action runs in Invoke Application, the navigation handler turns the outcome into a redirect and completes the response, so Render Response never runs for the POST (observed with a PhaseListener: the POST’s last phase was Invoke Application, on both implementations). The browser then starts a second request, which goes through the lifecycle on its own.

The pattern in one picture:

sequenceDiagram autonumber participant B as Browser participant F as FacesServlet participant S as Flash B->>F: POST /review.xhtml with the form fields F->>F: Invoke Application, ReviewFormBean.submit() F->>S: put lastReview and keep the messages F-->>B: 302 Location review-done.xhtml (no Render Response) B->>F: GET /review-done.xhtml F->>S: read lastReview and the kept messages F-->>B: 200 confirmation page B->>F: GET /review-done.xhtml (reload) F->>S: nothing left in the flash F-->>B: 302 back to review.xhtml

The Flash: What It Is

The API documentation describes jakarta.faces.context.Flash as a way to pass temporary objects between the views generated by the Faces lifecycle: anything placed in the flash is exposed to the next view the same user session encounters and then cleared out. The concept comes from Ruby on Rails. Three properties matter:

  • It is a Map<String, Object>. Flash is an abstract class that implements Map, so put, get, containsKey and remove all work.

  • It belongs to the user’s session, not to a browser tab, and must be thread safe. The values live on the server; the browser only receives a cookie that identifies the flash (see Mojarra and MyFaces). "The next view" therefore means the next request of that session, whichever tab sends it. Where the server keeps the values depends on the implementation: MyFaces 4.1.4 keeps them in the HTTP session, so they move with a replicated session, whereas Mojarra 4.1.17 keeps them in an application-wide store that it finds through the cookie, so on a second node the entry is not there even when the session was replicated (observed with two Tomcat nodes, see the deployment and clustering page).

  • It looks at two logical maps. The documentation says that during the execute portion of the lifecycle the flash reads the map written by the previous traversal, and during the render portion it writes the map for the next one. In practice, a value you put while handling request N can be read in request N+1, in the action and in the rendered page alike.

You reach the flash in three ways, all of them observed to work on both implementations:

Access Where it fits

#{flash} in a value expression, for example #{flash.lastReview.bookTitle}

Facelets pages (the read-only flash implicit object; see Expression Language).

FacesContext.getCurrentInstance().getExternalContext().getFlash()

Any Java code that has the FacesContext.

@Inject Flash flash

CDI beans. The specification lists jakarta.faces.context.Flash among the Faces objects valid for @Inject (section 5.6.1); injecting it into a request-scoped bean worked on both implementations.

The members of Flash beyond Map:

Member Meaning

put(key, value)

Stores a value for the next traversal. A null value is reported as Flash.NULL_VALUE in the flash system events, because the source of an event object cannot be null.

putNow(key, value)

Stores a value readable in the current traversal (an alias for putting it in the request map). In EL: #{flash.now.key}. It is gone after the request unless you also keep it.

keep(key)

Promotes a value to the flash so that it is available on the next traversal as well. In EL: #{flash.keep.key}.

setKeepMessages(boolean), isKeepMessages()

Whether the FacesMessage instances queued in the current FacesContext are preserved for the next traversal. EL: #{flash.keepMessages}.

setRedirect(boolean), isRedirect()

Declares that the next request of the session will be a redirect. The navigation handler sets it for you (specification 7.4.2); you need it only when you issue a redirect yourself.

doPrePhaseActions(ctx), doPostPhaseActions(ctx)

Called by the lifecycle around every phase so the implementation can maintain the two maps. Application code never calls them; they matter if you write a FlashWrapper (see Architecture and the Component Model on factories).

Post-Redirect-Get End to End

The complete Bookshelf round trip has four parts: the form, the action that saves and redirects, the target view that shows the result, and a guard for people who open the target directly.

The form view (review.xhtml):

review.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head>
  <title>Review a book</title>
</h:head>
<h:body>
  <h:messages id="messages" globalOnly="true"/>
  <h:form id="review">
    <h:outputLabel for="title" value="Book"/>
    <h:inputText id="title" value="#{reviewFormBean.bookTitle}" required="true"/>
    <h:message for="title"/>

    <h:outputLabel for="rating" value="Rating (1-5)"/>
    <h:inputText id="rating" value="#{reviewFormBean.rating}">
      <f:validateLongRange minimum="1" maximum="5"/>
    </h:inputText>
    <h:message for="rating"/>

    <h:outputLabel for="comment" value="Comment"/>
    <h:inputTextarea id="comment" value="#{reviewFormBean.comment}"/>

    <h:commandButton id="send" value="Send" action="#{reviewFormBean.submit}"/>
    <h:commandButton id="sendByRule" value="Send (rule)" action="#{reviewFormBean.submitByRule}"/>
    <h:commandButton id="show" value="Show" action="#{reviewFormBean.show}"/>
    <h:commandButton id="leave" value="Leave" action="#{reviewLeaveBean.leave}" immediate="true"/>
    <h:commandButton id="sendNaive" value="Send (no redirect)" action="#{reviewFormBean.submitNaive}"/>
  </h:form>
  <p>Reviews stored so far: #{reviewDoneBean.total}</p>
</h:body>
</html>

The action (the same bean as before, now with the redirecting method). It saves the review, queues a global message, asks the flash to keep it, hands the review itself to the next view and returns an outcome with faces-redirect=true:

ReviewFormBean
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.Flash;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.model.Review;
import org.example.bookshelf.service.ReviewService;

@Named
@RequestScoped
public class ReviewFormBean {

    @Inject
    private ReviewService reviews;
    @Inject
    private FacesContext facesContext;
    @Inject
    private Flash flash;

    private String bookTitle;
    private int rating = 5;
    private String comment;

    /** Without Post/Redirect/Get: the POST response itself shows the confirmation. */
    public String submitNaive() {
        reviews.save(new Review(bookTitle, rating, comment));
        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Thanks for your review.", null));
        return null;
    }

    /** Post/Redirect/Get: save, queue a message, hand the review to the next view, redirect. */
    public String submit() {
        Review review = new Review(bookTitle, rating, comment);
        reviews.save(review);
        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Thanks for your review.", null));
        flash.setKeepMessages(true);
        flash.put("lastReview", review);
        return "review-done?faces-redirect=true";
    }

    /** Same, but the outcome is resolved by a navigation rule with a redirect element. */
    public String submitByRule() {
        Review review = new Review(bookTitle, rating, comment);
        reviews.save(review);
        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Thanks for your review.", null));
        flash.setKeepMessages(true);
        flash.put("lastReview", review);
        return "reviewed";
    }

    /** Redirect to the page of the reviewed book, passing the title as a view parameter. */
    public String show() {
        return "show";
    }

    public String getBookTitle() { return bookTitle; }
    public void setBookTitle(String bookTitle) { this.bookTitle = bookTitle; }
    public int getRating() { return rating; }
    public void setRating(int rating) { this.rating = rating; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}

The target view (review-done.xhtml) reads the review from #{flash.lastReview} and shows the kept message with h:messages. Its f:viewAction runs in Invoke Application of the GET request and protects the page:

review-done.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<f:metadata>
  <f:viewAction action="#{reviewDoneBean.guard}"/>
</f:metadata>
<h:head>
  <title>Review saved</title>
</h:head>
<h:body>
  <h:messages id="messages" globalOnly="true"/>
  <p>Your review of #{flash.lastReview.bookTitle}: #{flash.lastReview.rating}/5</p>
  <h:link outcome="review" value="Review another book"/>
</h:body>
</html>
ReviewDoneBean
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.Flash;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.model.Review;
import org.example.bookshelf.service.ReviewService;

@Named
@RequestScoped
public class ReviewDoneBean {

    @Inject
    private FacesContext facesContext;
    @Inject
    private Flash flash;
    @Inject
    private ReviewService reviews;

    /** f:viewAction: visitors who did not come from the form go back to it. */
    public String guard() {
        if (flash.containsKey("lastReview")) {
            return null;
        }
        // MyFaces 4.1.4 stores the queued message in the session: make sure one exists
        facesContext.getExternalContext().getSession(true);
        facesContext.addMessage(null, new FacesMessage("Nothing to show: submit the form first."));
        return "review?faces-redirect=true";
    }

    public Review getLastReview() {
        return (Review) flash.get("lastReview");
    }

    private String title;

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public int getTotal() {
        return reviews.count();
    }
}

What was observed when this was driven with curl, identically on Mojarra and MyFaces:

Step Request Result

1

POST /review.xhtml with the Send button

302 with Location: /bookshelf/review-done.xhtml. The last lifecycle phase that ran was Invoke Application.

2

GET /review-done.xhtml

200: Your review of Dune: 4/5 and the message Thanks for your review..

3

GET /review-done.xhtml again (reload)

The guard found no lastReview: 302 back to /review.xhtml, and the confirmation message did not reappear.

4

POST with the rating 9

200, the form again with the validation message. Conversion and validation failures skip Invoke Application, so the action did not run and there was no redirect.

Two details of the guard are worth copying. First, f:viewAction runs on the initial GET request because the view has metadata, so a visitor who reaches review-done.xhtml with an empty flash is sent back to the form instead of seeing a page with blanks. Second, the specification says that when a navigation happens while a UIViewAction is being broadcast, the runtime calls getFlash().setKeepMessages(true) itself (7.4.2). That is why the guard’s message reached review.xhtml although the guard never calls setKeepMessages. This was observed on both implementations, with one MyFaces exception described under Pitfalls.

Three Ways to Redirect

The flash only needs to know that the next request is a redirect, and the navigation handler tells it. There are three ways to get a redirect:

Implicit navigation with faces-redirect=true. The outcome string of the action (or of a h:link or h:button) is a view id with a query string. The example above used "review-done?faces-redirect=true". The specification defines the parameter and calls getFlash().setRedirect(true) before it sends the redirect and completes the response.

A navigation case with a <redirect/> element. The outcome stays a plain string and the redirect is configured in faces-config.xml, so the bean does not know how its result is displayed:

faces-config.xml, a navigation case with a redirect element (inside a navigation-rule for /review.xhtml)
<navigation-case>
    <from-outcome>reviewed</from-outcome>
    <to-view-id>/review-done.xhtml</to-view-id>
    <redirect/>
</navigation-case>

The reviewed rule behaves exactly like the implicit outcome (the review was shown on the other side on both implementations):

The action that returns the rule’s outcome
    /** Same, but the outcome is resolved by a navigation rule with a redirect element. */
    public String submitByRule() {
        Review review = new Review(bookTitle, rating, comment);
        reviews.save(review);
        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Thanks for your review.", null));
        flash.setKeepMessages(true);
        flash.put("lastReview", review);
        return "reviewed";
    }

ExternalContext.redirect(String). A hand-made redirect, for example to an external site or from code that has no outcome. This bypasses the navigation handler, so nobody calls setRedirect(true). On Mojarra the flash survived such a redirect anyway; on MyFaces the value was lost until flash.setRedirect(true) was called before redirect(...). To be portable always call it:

A hand-made redirect that carries the flash on both implementations
    /** A hand-made redirect: the navigation handler is bypassed, so tell the flash. */
    public void leave() throws IOException {
        flash.put("lastReview", new Review("Dune", 5, null));
        flash.setRedirect(true);
        ExternalContext external = facesContext.getExternalContext();
        external.redirect(external.getRequestContextPath() + "/review-keep.xhtml");
    }

Both implementations answered the redirect with the status 302 Found. HTTP also defines 303 See Other, the status whose meaning is "fetch the result with a GET"; browsers follow a 302 that answers a POST with a GET as well, which is why the pattern works with either status (general HTTP behaviour, not tested here beyond curl).

The same applies to the full navigation story, which is a page of its own: outcomes, conditional navigation and includeViewParams are covered by the navigation page (see its section on state across a redirect).

Messages Across a Redirect

Messages are queued on the FacesContext of one request, so they die with it. A redirect ends the request, and the messages queued before it are gone by the time the GET arrives. Calling flash.setKeepMessages(true) before the redirect saves them into the flash and restores them on the next request.

Observed on both implementations, with the same action once with and once without the call:

  • With setKeepMessages(true): the redirected page showed Saved. in h:messages.

  • Without it: the redirected page showed no message, although the action had queued one.

  • A reload of the redirected page showed no message: kept messages are consumed by the first request that follows.

Advice that follows from this:

  • Queue global messages (client id null) when a redirect follows. The next view normally contains other components, so a message addressed to a component id of the form would have no matching h:message there (a design argument, not something this page ran).

  • Show them with h:messages globalOnly="true" in a place that every target view shares (typically the template; see Messages and Internationalization).

  • Keep validation messages out of this mechanism. When validation fails there is no redirect (step 4 above), so the usual h:message next to the field is the right tool.

The API documentation also shows the EL spelling of the same switch, <c:set target="#{flash}" property="keepMessages" value="true"/>, for pages that have no action (not run here).

How Long a Flash Entry Lives

An entry lives until the next request of the session has finished, then it is removed. Entries do not survive two requests unless they are kept. Observed with the redirected page of the example:

  • The first GET after the redirect showed the review; the second GET (reload) showed nothing. On the first GET the value was available in the action, in EL and in the rendered page.

  • A GET of a different page between the redirect and the intended target consumed the value, so the intended target then found nothing (both implementations). The flash is per session, not per browser tab: with two tabs open, whichever tab makes the next request wins. A second tab, or a request for any other page, can therefore take the value. Keep the flash for short, immediate hand-offs and give the target page a fallback (the guard above).

  • An entry put during a request that did not redirect (the action returned a normal outcome and the next view was shown by a forward) was visible in the page of that very response and then disappeared; a following GET found nothing. Do not rely on the flash for forwards.

To make a value survive one more request, keep it. In EL, reading it through flash.keep promotes it every time it is read:

review-keep.xhtml: the value survives every reload
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head>
  <title>Review saved</title>
</h:head>
<h:body>
  <p>Your review of #{flash.keep.lastReview.bookTitle}</p>
  <h:link outcome="review-keep" value="Reload"/>
</h:body>
</html>

Or in Java, for example from a preRenderView listener, when the page cannot be written with flash.keep:

review-keep-java.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<f:metadata>
  <f:event type="preRenderView" listener="#{reviewLeaveBean.keepForNextRequest}"/>
</f:metadata>
<h:head>
  <title>Review saved</title>
</h:head>
<h:body>
  <p>Your review of #{flash.lastReview.bookTitle}</p>
</h:body>
</html>
Keeping an entry for one more request
    /** Called before the page renders: keep the value for one more request. */
    public void keepForNextRequest() {
        flash.keep("lastReview");
    }

Both forms were run: after the redirect, three consecutive GETs of either page still showed the review on both implementations. The Javadoc of keep talks about values stored with putNow or in the request map; keeping a value that came from the previous request also worked in practice, but the documentation does not promise it, so treat the re-keep as an implementation behaviour. Keep entries sparingly: each kept value stays on the server for as long as the user keeps requesting pages that keep it.

h:link and h:button render ordinary links that the browser follows with a GET, so there is no action in which to call flash.put. Pass what the next view needs as request parameters instead (f:param), and read them with f:viewParam. The Flash documentation shows the pattern with flash.now: the view parameter writes into #{flash.now.picked}, which is the request map, so it is readable by the beans and the page of that request.

review-link.xhtml: parameters to the flash.now map
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<f:metadata>
  <f:viewParam name="pick" value="#{flash.now.picked}"/>
</f:metadata>
<h:head>
  <title>Picked author</title>
</h:head>
<h:body>
  <p>Picked: #{flash.now.picked}</p>
  <h:link outcome="review-link" value="Pick Emma">
    <f:param name="pick" value="Emma"/>
  </h:link>
</h:body>
</html>

Observed: opening review-link.xhtml?pick=Emma showed Picked: Emma; the link rendered the parameter as ?pick=Emma; a following request without the parameter showed Picked: with nothing (both implementations). The documentation’s own example then reads the value as #{flash.picked} (without now) on the same page. That did not show the value in this run on either implementation: flash.now.picked did. Use flash.now for reading in the same request.

Flash or Query Parameters?

The flash is the right carrier for one-shot data: a confirmation message, the object that was just created. It is the wrong carrier for identity. Data that says which resource the next page shows (a book id, a search term) belongs in the URL, because a URL can be bookmarked, shared and reloaded, and the flash cannot:

Flash Query parameter (f:viewParam)

Survives reload

No, unless kept

Yes

Bookmarkable and shareable

No

Yes

Visible to the user

No (stays on the server)

Yes, so it must be validated like any input

Size and type

Any object the flash holds (keep it small and Serializable)

Strings, converted by Faces

Good for

Confirmations, the just-created object

Which book, which page, which filter

A good PRG often uses both: the identity as a parameter and the message through the flash. A navigation rule can build the query string from an expression:

Excerpt of the earlier faces-config.xml: the show outcome
<navigation-case>
    <from-outcome>show</from-outcome>
    <to-view-id>/review-param.xhtml</to-view-id>
    <redirect>
        <view-param>
            <name>title</name>
            <value>#{reviewFormBean.bookTitle}</value>
        </view-param>
    </redirect>
</navigation-case>

The rule ran on both implementations: for the title A & B the redirect went to /review-param.xhtml?title=A%26+B+ and the target page read the original title back.

Do not build the query string by concatenating user input into the outcome, as in "review-param?faces-redirect=true&title=" + title. With the title A & B=C Mojarra split it into two parameters (the page saw A), and MyFaces produced an invalid URL with a space and an ampersand. Encoding by hand did not help portably either: URLEncoder.encode worked on Mojarra, but MyFaces encoded the already encoded value a second time. Prefer <view-param> in a rule, or keep to identifiers (an ISBN or a numeric id) in the outcome string. Non-ASCII text in a redirect parameter also depends on the character encoding: in an earlier run without any declared encoding, MyFaces encoded ü in Latin-1 (%FC) and Tomcat answered the follow-up GET with status 500, while Mojarra used UTF-8. After declaring the encoding in web.xml (Servlet 4.0 and later) both worked:

web.xml, declare the character encoding
<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>

For the view parameters themselves (conversion, validation, 404 handling), the view parameters and view actions page is the reference.

Redirects After an Ajax Request

A command button with f:ajax can redirect too. The page below posts the same submit action as an Ajax request:

review-ajax.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head>
  <title>Review a book (Ajax)</title>
</h:head>
<h:body>
  <h:messages id="messages" globalOnly="true"/>
  <h:form id="review">
    <h:outputLabel for="title" value="Book"/>
    <h:inputText id="title" value="#{reviewFormBean.bookTitle}" required="true"/>
    <h:message id="titleMessage" for="title"/>

    <h:commandButton id="send" value="Send" action="#{reviewFormBean.submit}">
      <f:ajax execute="@form" render="@form messages"/>
    </h:commandButton>
  </h:form>
</h:body>
</html>

The response of an Ajax request is XML and not a page, so Faces answers with a redirect element and the Faces JavaScript library navigates the browser (the Ajax chapter of the specification defines this element). Observed on both implementations:

The Ajax response (captured with curl)
<partial-response><redirect url="/bookshelf/review-done.xhtml"></redirect></partial-response>

The GET that follows found the review and the kept message, exactly as in the non-Ajax case. So PRG needs no special code for Ajax.

Flash System Events

The flash publishes system events, so you can audit or react to it: PostPutFlashValueEvent when a value is stored, PostKeepFlashValueEvent when one is kept, PreRemoveFlashValueEvent when one is removed and PreClearFlashEvent before the flash is cleared. The event source is the key (or, for the clear event, the map). The specification text that lists the system events spells the first ones PostKeepFlashEvent and PostPutFlashEvent; the API classes carry Value in the name, as written here.

Register a listener in faces-config.xml (see Core Tags for f:event on views and the the events and listeners page for the model):

faces-config.xml (excerpt): listen to the flash
<application>
    <system-event-listener>
        <system-event-listener-class>org.example.bookshelf.web.FlashAudit</system-event-listener-class>
        <system-event-class>jakarta.faces.event.PostPutFlashValueEvent</system-event-class>
    </system-event-listener>
    <system-event-listener>
        <system-event-listener-class>org.example.bookshelf.web.FlashAudit</system-event-listener-class>
        <system-event-class>jakarta.faces.event.PostKeepFlashValueEvent</system-event-class>
    </system-event-listener>
</application>
FlashAudit
package org.example.bookshelf.web;

import jakarta.faces.event.SystemEvent;
import jakarta.faces.event.SystemEventListener;
import java.util.logging.Logger;

public class FlashAudit implements SystemEventListener {

    private static final Logger LOG = Logger.getLogger(FlashAudit.class.getName());

    @Override
    public void processEvent(SystemEvent event) {
        LOG.info(() -> event.getClass().getSimpleName() + ": " + event.getSource());
    }

    @Override
    public boolean isListenerForSource(Object source) {
        return true;
    }
}

With this registered, a PRG logged PostPutFlashValueEvent: lastReview on both implementations, and keeping the entry logged PostKeepFlashValueEvent: lastReview. PreClearFlashEvent fired on both as well; PreRemoveFlashValueEvent was not triggered in these runs.

Mojarra and MyFaces

The observable behaviour was the same for the PRG, the kept messages, the single-use lifetime, flash.keep, Ajax redirects and the events. The differences found:

Mojarra 4.1.17 MyFaces 4.1.4

Cookie sent for the flash

csfcfc (HttpOnly, opaque value)

oam.Flash.RENDERMAP.TOKEN and oam.Flash.REDIRECT (HttpOnly, SameSite=Strict)

ExternalContext.redirect without flash.setRedirect(true)

Flash survived

Flash was lost

Message queued in a request that has no session yet and ends in a redirect

Worked

500, see Pitfalls

Redirect outcome with hand-built query string

Splits unencoded &; pre-encoded values correct

Unencoded value produced an invalid URL; pre-encoded values were encoded twice

Where the entries are kept

In an application-wide store found through the cookie; a second node with the replicated session did not see the entry

In the HTTP session; a second node with the replicated session saw the entry (two-node run, see the clustering page)

Context parameters (from the implementations' docs, not run)

com.sun.faces.forceAlwaysWriteFlashCookie, com.sun.faces.numberOfConcurrentFlashUsers, com.sun.faces.numberOfFlashesBetweenFlashReapings, com.sun.faces.enableTransitionTimeNoOpFlash

org.apache.myfaces.FLASH_SCOPE_DISABLED, org.apache.myfaces.NUMBER_OF_FLASH_TOKENS_IN_SESSION, org.apache.myfaces.USE_FLASH_SCOPE_PURGE_VIEWS_IN_SESSION

The cookie names are implementation details I observed, not something the specification defines; they may change between releases. The Mojarra documentation describes the first parameter as writing the flash cookie on every response and the second as the maximum number of concurrent flash users (default 5000, available since 4.0.7). The MyFaces documentation says the first one defines whether the flash scope is disabled (no flash cookie is added), and that the purge parameter lets server-side state saving delete old views even when PRG is used.

What Changed in Which Version

  • 2.0: the flash scope, ExternalContext.getFlash() and the flash EL object are introduced (Since: 2.0 in the API documentation); setKeepMessages and setRedirect come with it.

  • 2.2: the change log lists events from the flash (issue 766) and a FlashFactory (issue 1071), the delegating implementation support behind FlashWrapper. The same change log lists a change of the default navigation algorithm to account for UIViewAction (the automatic setKeepMessages rule described above).

  • 4.x: the changes lists of recent 4.x releases mention an HttpOnly flash cookie (Mojarra and MyFaces both sent it in this run); the Flash documentation does not define a cookie.

  • 5.0 (upcoming): the milestone releases remove deprecated APIs; this page was not checked against them and nothing here should be read as describing 5.0.

Where the Books Are Outdated

The oldest books in the bibliography, JavaServer Faces in Action (2004), Mastering JavaServer Faces (2004) and Pro JSF and Ajax (2006), were published before JSF 2.0 introduced the flash, so they cannot describe it. A JSF 1.x application that wanted a Post/Redirect/Get had a <redirect/> navigation case, request parameters, or a session-scoped bean to hold the confirmation data. The last is a poor trade: the data outlives its purpose and is shared by every tab of the session. The flash replaces that bean with an entry that disappears by itself. JavaServer Faces 2.0: The Complete Reference and the JSF 2.0 Cookbook target JSF 2.0, the release that added the flash, but their code uses the javax.faces packages and @ManagedBean, which are now jakarta.faces and CDI. Everything on this page was checked on 4.1.

Pitfalls

  • Forgetting setKeepMessages(true). The redirect works, the page loads, but the confirmation never appears. See Messages Across a Redirect.

  • Storing big or non-serializable objects. On MyFaces the flash lives in the session, and a session that is replicated or persisted needs small Serializable values; the Review record implements Serializable for that reason. Do not count on the flash in a cluster, though: on Mojarra 4.1.17 the entry stayed on the node that stored it, and the redirect target found nothing when it ran on another node, even with a replicated session. Use sticky routing at least for the redirect, or carry the data in a query parameter (the clustering page has the two-node run).

  • Reading the flash on a page that can be opened directly. After a reload or a bookmark the flash is empty. Guard such pages with an f:viewAction as shown, or have them work without it.

  • Two tabs and prefetching. The first request of the session after the redirect consumes the entry. See How Long a Flash Entry Lives.

  • ExternalContext.redirect on MyFaces. Call flash.setRedirect(true) first. See Three Ways to Redirect.

  • MyFaces: a redirect with a message on the very first request of a session. A visitor whose first request is review-done.xhtml (no session cookie yet) was redirected by the guard on both implementations, but on MyFaces 4.1.4 the response ended with 500 and java.lang.IllegalStateException: Cannot create a session after the response has been committed, thrown from FlashImpl._saveMessages while the flash tried to create the session. Mojarra handled the same request. The remedy used in the guard above is to create the session before queueing the message, facesContext.getExternalContext().getSession(true). With it, the first request got its 302 and a session cookie, and the message appeared on both implementations. An already existing session did not show the problem.

  • Hand-built redirect URLs. See Flash or Query Parameters?.

  • Relying on the flash for forwards. A navigation without faces-redirect=true keeps the POST URL in the address bar and discards the flash entry after the response.

  • Double clicks. PRG does not stop a user from clicking Send twice before the first response arrives. Make the operation idempotent or disable the button in the browser; PRG only solves the reload and back-button case (reasoning, not run).