GWT-RPC
|
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 GWT 2.13.1 (released 2026-06-19), gwt-maven-plugin 1.3.0 (net.ltgt.gwt.maven), JDK 21 running the compiler and the server with -sourceLevel 17, the Jakarta Servlet 6 server (Jetty 12.1.10, EE 10) of the Bookshelf scenario and the gwt-user and gwt-servlet-jakarta 2.13.1 jars and sources, checked on 2026-10-09.
GWT-RPC spans three kinds of code at once.
The shared interfaces and data classes are compiled twice, once by GWT to JavaScript and once by javac to bytecode.
The client part is the generated proxy, the AsyncCallback objects and the code of the calling presenter, all compiled to JavaScript.
The server part is a servlet that decodes the request, calls your Java method and encodes the result.
The build has to deploy one extra artifact, the serialisation policy file that the compiler writes next to the JavaScript.
Every output block on this page was produced by running the examples in a private copy of the Bookshelf scenario: a lab module compiled with the 2.13.1 compiler, served by Jetty 12 on port 8932 and driven by headless Chrome 155 through the DevTools protocol, probe modules compiled with the 2.13.1 compiler for the generator messages, curl against the servlet for the wire format, and plain JUnit tests on JDK 21 for the presenter.
Where a statement comes only from the Developer Guide, the Javadoc or the 2.13.1 source, the text says so.
GWT-RPC is a remote procedure call mechanism between a GWT client and a Java servlet that is built into the toolkit.
You write a Java interface, a servlet that implements it and a callback, and GWT generates the code that turns method arguments and results into a text payload, sends it over XMLHttpRequest and turns the answer back into Java objects.
The Developer Guide is explicit about what it is not: the service is not a SOAP or REST web service, it is not meant to be a public API for other clients, and it does not replace a Jakarta EE back end.
It is "getting from the client to the server" for the application that shipped the client, and the rest of this page follows from that.
Other ways to talk to a server (JSON, XML, REST libraries, RequestFactory) are compared on the communication overview page (Client-Server Communication).
The moving parts
A service is a small family of types, and the naming rules between them are checked by the compiler.
| Type | Runs in | Who writes it and what it does |
|---|---|---|
|
shared, client (as a type), server |
You. The synchronous interface is the definitive contract. |
|
shared, client |
You (or an IDE). Same package and name plus |
|
client |
Generated by |
|
client |
Generated per service. One serialiser per type in the policy, with the type signature hash that the server checks. |
|
client |
You. |
|
server |
You. The servlet class that is mapped to the URL. It implements the synchronous interface, not the Async one. |
|
written by the compiler, read by the server |
The serialisation policy: the whitelist of types that may travel, one file per service interface and build. |
|
build output |
Maps each service class to its policy file. It is written to the compiler’s deploy directory ( |
The compiler also reads the rpc.blacklist and rpc.enhancedClasses configuration properties and the gwt.suppressNonStaticFinalFieldWarnings and rpc.XserializeFinalFields properties that RemoteService.gwt.xml defines; they appear in the sections where they matter.
The module that declares all of this is already inherited: com.google.gwt.user.User inherits com.google.gwt.user.RemoteService, which inherits com.google.gwt.http.HTTP, so a module that inherits User needs no extra <inherits> line for RPC.
If your module inherits a smaller set (for example only com.google.gwt.core.Core), add <inherits name="com.google.gwt.user.RemoteService"/>.
The life of one call
The diagram follows getBooks(0, 10) of the Bookshelf catalogue from the presenter to the callback.
The names of the checks are the real ones of the 2.13.1 sources.
Three things in the diagram explain most of the pitfalls later on. First, nothing is sent until the browser’s event loop is free again: the call returns immediately and the callback runs later (see Asynchronous calls). Second, the server decides what may be deserialised from the policy file, not from the class path, which is why a missing policy file breaks every call (see Serialisation policy files). Third, an error can happen at four different places (the proxy, the transport, the servlet, the callback), and each place reports through a different exception (see Exceptions: everything that can reach onFailure).
Defining the Bookshelf CatalogService
The running scenario of this section is the Bookshelf (books, authors, reviews, loans) as a three-module GWT application: bookshelf-shared, bookshelf-client and bookshelf-server (see Project structure and modules).
The service lives in the shared module because both sides compile it.
package com.example.bookshelf.shared;
import com.google.gwt.user.client.rpc.RemoteService;
import com.google.gwt.user.client.rpc.RemoteServiceRelativePath;
/** GWT-RPC service. Mapped to the module base URL plus "catalog". */
@RemoteServiceRelativePath("catalog")
public interface CatalogService extends RemoteService {
BookPage getBooks(int start, int length);
Book getBook(long id) throws BookNotFoundException;
Book saveBook(Book book) throws BookNotFoundException, AccessDeniedException;
int lend(long id) throws BookNotFoundException;
java.util.ArrayList<Review> reviews(long bookId) throws BookNotFoundException;
java.util.HashMap<Format, Integer> copiesByFormat();
int revision();
}
package com.example.bookshelf.shared;
import com.google.gwt.http.client.Request;
import com.google.gwt.http.client.RequestBuilder;
import com.google.gwt.user.client.rpc.AsyncCallback;
public interface CatalogServiceAsync {
void getBooks(int start, int length, AsyncCallback<BookPage> callback);
Request getBook(long id, AsyncCallback<Book> callback);
RequestBuilder saveBook(Book book, AsyncCallback<Book> callback);
void lend(long id, AsyncCallback<Integer> callback);
void reviews(long bookId, AsyncCallback<java.util.ArrayList<Review>> callback);
void copiesByFormat(AsyncCallback<java.util.HashMap<Format, Integer>> callback);
void revision(AsyncCallback<Integer> callback);
}
The rules that the generator enforces, all from ServiceInterfaceProxyGenerator:
-
The Async interface has the same package and simple name plus
Async:com.example.bookshelf.shared.CatalogServiceAsyncforcom.example.bookshelf.shared.CatalogService. -
Each synchronous method has one asynchronous method with the same name and the same parameters plus a last
AsyncCallback<R>parameter, whereRis the return type boxed (Integerforint,Voidforvoid). -
The asynchronous return type is
void,com.google.gwt.http.client.Requestorcom.google.gwt.http.client.RequestBuilder. ARequestlets the caller askisPending()andcancel(). ARequestBuildermeans that the call is prepared but not sent until the caller invokessend(), so the caller can still change headers or the timeout. -
A synchronous method may declare checked exceptions; they travel back as the exceptions of
onFailure(see Exceptions: everything that can reach onFailure). -
@RemoteServiceRelativePathis a runtime-retained annotation on the synchronous interface. Its value is resolved againstGWT.getModuleBaseURL(). Without the annotation the service has no URL untilServiceDefTarget.setServiceEntryPoint(String)is called.
When the generator cannot find the Async interface, or a method is missing, the compile fails with messages that print the interface it expected.
The two cases below were reproduced with probe interfaces named P7 and P8:
Checking the synchronous interface 'com.example.probe.client.Services.P7' against its asynchronous version 'com.example.probe.client.Services.P7Async'
[ERROR] Missing asynchronous version of the synchronous method 'public abstract java.lang.String hello(java.lang.String name)'
A valid definition for the asynchronous version of interface 'com.example.probe.client.Services.P7' would be:
package com.example.probe.client;
public interface P7Async {
void hello(java.lang.String name, com.google.gwt.user.client.rpc.AsyncCallback<java.lang.String> arg2);
}
[ERROR] Could not find an asynchronous version for the service interface com.example.probe.client.Services.P8
The generator prints a ready-to-paste Async interface, which is the quickest way to write the first version.
The messages also appear as Failed to resolve '...' via deferred binding on the line of the GWT.create call (see Deferred binding and generators for how generators report errors).
The data classes of the example
Book is a plain DTO.
It implements java.io.Serializable, has a public no-argument constructor, and keeps server-only bookkeeping in a transient field.
public class Book implements Serializable {
private long id;
private String title;
private String author;
private int year;
private int copies;
private Isbn isbn; // immutable value type: needs a custom field serializer
private Format format = Format.PAPERBACK; // enum: travels as its ordinal
private Date added; // java.util.Date: built-in serializer
private transient String internalNote; // never sent in either direction
public Book() {}
public Book(long id, String title, String author, int year, int copies) { /* ... */ }
// getters and setters
}
BookPage carries one page of the catalogue and the total number of rows, which is what a paged cell widget needs (see Paging a cell widget through the service).
public class BookPage implements Serializable {
private ArrayList<Book> books = new ArrayList<>();
private int total;
public BookPage() {}
public BookPage(List<Book> books, int total) {
this.books = new ArrayList<>(books);
this.total = total;
}
public List<Book> getBooks() { return books; }
public int getTotal() { return total; }
}
Implementing the service
A service implementation is a servlet.
It extends RemoteServiceServlet and implements the synchronous interface.
GWT 2.13.1 ships the servlet in two flavours, because the servlet API changed its package name between Servlet 4 and Servlet 5.
javax.servlet |
jakarta.servlet |
|
|---|---|---|
Maven artifact |
|
|
Base class |
|
|
XSRF servlets |
|
|
Other servlet classes |
|
the same names in |
Servlet API you provide |
|
|
Containers |
those that implement Servlet 4.0 or older |
those that implement Servlet 5.0 or later (Jetty 12 EE 10 is what the examples ran on) |
The classes that do not touch the servlet API stay in the old package and are shared by both artifacts: RPCRequest, SerializationPolicy, SerializationPolicyLoader, SerializationPolicyProvider, ServerCustomFieldSerializer, UnexpectedException, and the annotations XsrfProtect and NoXsrfProtect.
The client-side RPC types (RemoteService, AsyncCallback, the exceptions) are identical in both artifacts and live in com.google.gwt.user.client.rpc.
Neither artifact depends on a servlet API; you supply it (as provided scope in a WAR).
In the jars of 2.13.1 the javax jar holds RemoteServiceServlet only in com.google.gwt.user.server.rpc, and the jakarta jar holds it only in com.google.gwt.user.server.rpc.jakarta, so a servlet written for one flavour does not compile against the other jar.
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>bookshelf-shared</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.gwtproject</groupId>
<artifactId>gwt-servlet-jakarta</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
The javax variant of the same class compiled against gwt-servlet-2.13.1.jar and javax.servlet-api 3.1.0; it was not run in a container:
import com.google.gwt.user.server.rpc.RemoteServiceServlet; // javax.servlet flavour: gwt-servlet, not gwt-servlet-jakarta
public class LegacyCatalogImpl extends RemoteServiceServlet implements NoPathService {
@Override
public String ping() {
return getThreadLocalRequest().getContextPath(); // javax.servlet.http.HttpServletRequest
}
}
package com.example.bookshelf.server;
import com.google.gwt.user.server.rpc.RPCRequest;
import com.google.gwt.user.server.rpc.jakarta.RemoteServiceServlet;
import jakarta.servlet.http.HttpServletRequest;
// other imports omitted
public class CatalogServiceImpl extends RemoteServiceServlet implements CatalogService {
/** Called after the request was decoded and before the service method runs: a place for audit logging. */
@Override
protected void onAfterRequestDeserialized(RPCRequest rpcRequest) {
log("rpc " + rpcRequest.getMethod().getName() + " with " + rpcRequest.getParameters().length + " parameter(s)");
}
@Override
public BookPage getBooks(int start, int length) {
if (start < 0 || length < 0) {
throw new IllegalArgumentException("start and length must be >= 0");
}
return CatalogRepository.INSTANCE.page(start, Math.min(length, 100));
}
@Override
public Book getBook(long id) throws BookNotFoundException {
return CatalogRepository.INSTANCE.get(id);
}
@Override
public Book saveBook(Book book) throws BookNotFoundException, AccessDeniedException {
// Authorisation is a server-side decision: never trust a flag sent by the browser.
if (getThreadLocalRequest().getUserPrincipal() == null) {
throw new AccessDeniedException();
}
if (book.getTitle() == null || book.getTitle().isBlank() || book.getTitle().length() > 200) {
throw new IllegalArgumentException("Invalid title");
}
return CatalogRepository.INSTANCE.save(book);
}
// lend, reviews, copiesByFormat and revision omitted
}
This excerpt is the plain, unprotected form used to introduce the servlet API. Because saveBook changes state, the
deployed Bookshelf service extends the jakarta XsrfProtectedServiceServlet instead, and its onAfterRequestDeserialized
override calls super.onAfterRequestDeserialized(rpcRequest) first so the token check still runs (see
XSRF protection).
getBooks caps the page size at 100 (Math.min(length, 100)) because the length comes from the browser.
Treat every argument as hostile input and validate it again on the server; the client-side checks of the user interface are a convenience only.
Mapping the servlet to a URL
The URL of a service is the module base URL plus the value of @RemoteServiceRelativePath.
The module base URL is the directory that contains the module’s *.nocache.js file, with the context path in front: with rename-to="bookshelf" and a web application deployed at the root, GWT.getModuleBaseURL() is http://localhost:8932/bookshelf/ and the servlet is mapped to /bookshelf/catalog.
<?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_5_0.xsd"
version="5.0">
<servlet>
<servlet-name>catalogServlet</servlet-name>
<servlet-class>com.example.bookshelf.server.CatalogServiceImpl</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>catalogServlet</servlet-name>
<url-pattern>/bookshelf/catalog</url-pattern>
</servlet-mapping>
</web-app>
The annotation-based alternative needs no web.xml entry.
It was run in Jetty 12 EE 10 and gave the same lab results as the web.xml mapping:
@WebServlet(name = "catalogServlet", urlPatterns = "/bookshelf/catalog")
public class CatalogServiceImpl extends RemoteServiceServlet implements CatalogService {
// ...
}
Three details of the mapping decide whether calls arrive:
-
The part of the pattern before the last segment must equal the module base path. If the module is renamed with
rename-to, the renamed value is used; either way the segment must be the directory inside the WAR where the module’s files are served. -
The servlet only handles POST. A
GETof the same URL answers405 Method Not Allowed, becauseAbstractRemoteServiceServlet.doPostis the only handler (doPostisfinal). -
The servlet must be in the same web application as the module’s files: it loads the policy file with
ServletContext.getResourceAsStream, and the module path must start with the context path (see Serialisation policy files).
A servlet around a plain service class
RemoteServiceServlet has a second constructor, RemoteServiceServlet(Object delegate), for a service implementation that is not itself a servlet.
That makes the logic a plain class that can be unit-tested, reused, or created by a dependency-injection container.
/** Plain class: it does not extend a servlet. */
public class PingLogic implements NoPathService {
@Override
public String ping() {
return "pong";
}
}
/** Wraps a service implementation that is not itself a servlet. */
@WebServlet(name = "pingServlet", urlPatterns = "/bookshelf/ping")
public class PingServlet extends RemoteServiceServlet {
public PingServlet() {
super(new PingLogic());
}
}
NoPathService is a service interface without @RemoteServiceRelativePath, so the client sets the URL explicitly (see ServiceDefTarget and RpcRequestBuilder).
The call returned //OK[1,["pong"],0,7] in the lab.
In a Spring application the same constructor lets a @Service bean become the delegate of a servlet registered through a ServletRegistrationBean (the registration itself belongs to Server integration and deployment and is not repeated here).
What the servlet does for you, and the hooks it offers
The base classes implement a fixed pipeline. Each step has a protected method you can override; most applications override only the first two rows.
| Method | Use and default |
|---|---|
|
Called before the service method runs, with the decoded method and arguments. Default: nothing. A natural place for audit logging or per-method authorisation. In |
|
See the raw payload. Default: nothing. Never log whole payloads in production: they contain user data. |
|
The current |
|
The module path and the permutation hash from the |
|
Throws |
|
Public so that they can be tested without HTTP. Override to route the call to a framework component. |
|
Loads the policy; the default reads a servlet resource. Override to load policies from somewhere else. |
|
Gzip is used when the client sends |
|
Called when anything escapes the pipeline. The default logs the exception to the servlet context and answers |
|
Reads the body and enforces |
|
Super Dev Mode support (see Policies in Super Dev Mode). |
The gzip rule was checked with curl --header 'Accept-Encoding: gzip' against the lab: the 464-byte getBooks answer came back with Content-Encoding: gzip and Content-Length: 290, and the 14-byte revision answer came back uncompressed.
Every response also carries Content-Type: application/json;charset=utf-8 and Content-Disposition: attachment.
Calling the service from the client
The client side of a call always has the same three steps: create the proxy with GWT.create(), write the callback, make the call.
CatalogServiceAsync catalog = GWT.create(CatalogService.class); // the cast is implicit: the proxy implements the Async interface
catalog.getBooks(0, 10, new AsyncCallback<BookPage>() {
@Override
public void onSuccess(BookPage page) {
display.showRows(0, page.getBooks(), page.getTotal());
}
@Override
public void onFailure(Throwable caught) {
display.showError("The catalogue could not be loaded.");
}
});
The assignment from GWT.create(CatalogService.class) to the Async type compiles without a cast because GWT.create is generic; older code and the Developer Guide write (CatalogServiceAsync) GWT.create(...).
The proxy can be created once and shared by every presenter; it holds no per-call state except the optional RPC token and the request builder.
In the Bookshelf the ClientFactory creates it and the facade below wraps it.
Asynchronous calls
Every call returns before the server has answered.
The code after the call runs first, then the browser returns to its event loop, and only when the response arrives is onSuccess or onFailure invoked.
GWT offers no synchronous variant, because a synchronous XMLHttpRequest would freeze the single JavaScript thread of the page.
The consequences for the code you write are practical:
-
A field that the callback sets is not yet set on the line after the call. Move the follow-up work into the callback, or into a method the callback calls.
-
Two calls started one after the other may complete in either order. If the order matters, start the second call from the first one’s callback (see [chaining]).
-
If the user can trigger a call twice (a double click on a button), disable the control until the callback ran, or use
Request.cancel()on the earlier call. -
If the widget that owns the callback has been removed from the page when the answer arrives, the callback still runs. Check a flag or the attached state before touching the DOM.
The Developer Guide adds a claim that most browsers allow only two simultaneous connections. That figure comes from an older generation of browsers and HTTP/1.0 era limits; do not design around it, and measure on the target browsers instead.
The same-origin policy
A GWT-RPC call is an XMLHttpRequest like any other, so it obeys the browser’s same-origin policy.
By default the entry point is the module base URL plus the relative path, which is on the same origin as the page, and nothing needs configuring.
This is the reason the Bookshelf serves the host page, the compiled module and the servlet from one web application.
The GWT FAQ states that the policy limits every form of server call, GWT-RPC included, and recommends either serving the application and its services from one server or putting a reverse proxy in front of both.
Pointing setServiceEntryPoint at another origin is possible only if that server answers the CORS preflight (see ServiceDefTarget and RpcRequestBuilder and CORS), and the cross-site loading topic of the bootstrap (see Linkers and bootstrap) is a separate question: it concerns where the module’s JavaScript files are loaded from, not where the calls go.
Cancelling and preparing calls
An Async method that returns Request hands back an object that tracks the call.
In the lab, getBook(2) was cancelled right after it was made:
Request r = catalog.getBook(2, callback);
log("pending before cancel: " + r.isPending());
r.cancel();
log("pending after cancel: " + r.isPending());
pending before cancel: true
pending after cancel: false
callback fired after cancel: false
cancel() does not undo anything on the server, which may already have executed the method; it only guarantees that the callback will not run.
The returned Request can be null when the request could not even be started (doInvoke returns null after reporting the failure to the callback), so null-check it before use.
An Async method that returns RequestBuilder prepares the call without sending it.
The lab logs the payload when the builder is created and the response only after send():
>> 7|0|10|http://localhost:8932/bookshelf/|4F134919A31D9111F950CD21A4354BB3|com.example.bookshelf.shared.CatalogService|saveBook|...
built, not yet sent: http://localhost:8932/bookshelf/catalog
rb.send() called
<< 200 application/json;charset=utf-8 //EX[1,["com.example.bookshelf.shared.AccessDeniedException/35684112"],0,7]
If the arguments cannot be serialised, the generated code reports the error at once for void and Request methods and when send() is called for a RequestBuilder method (the generator returns a FailingRequestBuilder in that case).
A facade, callback chaining and plain Java presenters
Calling the proxy directly from every presenter spreads the plumbing.
A small facade that wraps the proxy gives presenters one place to configure the request builder and the error handling, and lets them pass lambdas instead of anonymous AsyncCallback classes.
/** Facade: the only class that knows the RPC proxy. */
public class CatalogGateway {
private final CatalogServiceAsync service = GWT.create(CatalogService.class);
public CatalogGateway(int timeoutMillis) {
((ServiceDefTarget) service).setRpcRequestBuilder(new BookshelfRpcRequestBuilder(timeoutMillis));
}
public CatalogServiceAsync raw() {
return service;
}
static <T> AsyncCallback<T> callback(Consumer<T> onSuccess, Consumer<Throwable> onFailure) {
return new AsyncCallback<T>() {
@Override
public void onSuccess(T result) {
onSuccess.accept(result);
}
@Override
public void onFailure(Throwable caught) {
onFailure.accept(caught);
}
};
}
public void books(int start, int length, Consumer<BookPage> ok, Consumer<Throwable> failed) {
service.getBooks(start, length, callback(ok, failed));
}
public void book(long id, Consumer<Book> ok, Consumer<Throwable> failed) {
service.getBook(id, callback(ok, failed));
}
/** Callback chaining: the second call starts from the first call's result. */
public void firstBookOfPage(int start, Consumer<Book> ok, Consumer<Throwable> failed) {
books(start, 5, page -> {
if (page.getBooks().isEmpty()) {
failed.accept(new IllegalStateException("empty page"));
} else {
book(page.getBooks().get(0).getId(), ok, failed);
}
}, failed);
}
}
AsyncCallback has two methods, so it is not a functional interface and cannot be written as a lambda directly; the static callback(...) adapter above is the small price.
The chained call (firstBookOfPage) ran in the lab and produced two requests, the second starting only after the first answered:
== 3 chaining firstBookOfPage(5)
>> 7|0|5|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|getBooks|I|1|2|3|4|2|5|5|5|5|
<< 200 application/json;charset=utf-8 //OK[25,1999,21,20,7,"K",-9,1,19,...
>> 7|0|5|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|getBook|J|1|2|3|4|1|5|G|
<< 200 application/json;charset=utf-8 //OK[2004,7,6,5,"G",2,4,1,3,"YvP5WgA",2,1,[...
OK 6 Working Effectively with Legacy Code
The == lines are the lab’s own step headings.
The "command pattern" chain that older books build from small command objects is, in current Java, a chain of lambdas like the one above; for long sequences, name each step as a method so that the nesting stays shallow.
The presenter should not call GWT.create() itself.
If the presenter receives the Async interface (or the facade) in its constructor, a plain JUnit test on the JVM can pass a hand-written fake that answers synchronously:
public class CatalogPresenter {
private final CatalogServiceAsync service;
private final CatalogDisplay display;
public CatalogPresenter(CatalogServiceAsync service, CatalogDisplay display) {
this.service = service;
this.display = display;
}
public void load(int start, int length) {
service.getBooks(start, length, new AsyncCallback<BookPage>() {
@Override
public void onSuccess(BookPage page) {
display.showRows(start, page.getBooks(), page.getTotal());
}
@Override
public void onFailure(Throwable caught) {
display.showError(messageFor(caught));
}
});
}
/** Never show getMessage() of an RPC failure: for a StatusCodeException it contains the whole response body. */
static String messageFor(Throwable caught) {
if (caught instanceof IncompatibleRemoteServiceException) {
return "This page is out of date. Please reload it.";
}
if (caught instanceof StatusCodeException) {
int status = ((StatusCodeException) caught).getStatusCode();
return status == 0 ? "The server cannot be reached." : "The server answered with status " + status + ".";
}
return "The request failed. Please try again.";
}
}
The four JUnit tests of this presenter ran in 0.016 s without compiling anything with GWT:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in com.example.bookshelf.client.CatalogPresenterTest
One of them feeds the presenter a StatusCodeException(500, "Server Error", "<html>stack trace of the server</html>") and asserts that the user sees only "The server answered with status 500.".
The test exists because getMessage() of a StatusCodeException is the status, the status text and the whole response body; the Developer Guide’s own sample, which prints caught.getMessage() into a label, would show a container error page to the user.
The Testing page covers GWTTestCase and mocking in more depth; the presenter and activity structure is in MVP, Activities & Places.
Paging a cell widget through the service
getBooks(start, length) returns a page plus the total, which is the shape that an AsyncDataProvider needs.
The provider asks for a range when the display needs one, the presenter calls the service, and the callback pushes rows and the row count back.
provider = new AsyncDataProvider<Book>() {
@Override
protected void onRangeChanged(HasData<Book> display) {
Range r = display.getVisibleRange();
listener.onRange(r.getStart(), r.getLength()); // the presenter calls CatalogPresenter.load(start, length)
}
};
// in CatalogDisplay.showRows(start, rows, total), called from the RPC callback:
provider.updateRowCount(total, true);
provider.updateRowData(start, rows);
In the scratch application this drove a DataGrid with ten rows per page over 25 books (1-10 of 25 in the pager).
Add the display to the provider only after the presenter exists, because addDataDisplay fires onRangeChanged immediately.
The cell widget side of this loop (providers, pagers, FieldUpdater) is on Cell widgets.
Polling and emulated server push
GWT-RPC is request and response: the server cannot call the browser.
The classic workaround is polling with a Timer.
The poller below asks for the catalogue’s revision number every few hundred milliseconds and reports a change:
public class RevisionPoller {
private final CatalogServiceAsync service;
private final IntConsumer onChange;
private int last = -1;
private boolean waiting;
private final Timer timer = new Timer() {
@Override
public void run() {
if (waiting) {
return; // never stack requests when the server is slow
}
waiting = true;
service.revision(new AsyncCallback<Integer>() {
@Override
public void onSuccess(Integer revision) {
waiting = false;
if (last >= 0 && revision != last) {
onChange.accept(revision);
}
last = revision;
}
@Override
public void onFailure(Throwable caught) {
waiting = false; // keep polling; a real application would back off
}
});
}
};
public RevisionPoller(CatalogServiceAsync service, IntConsumer onChange) {
this.service = service;
this.onChange = onChange;
}
public void start(int periodMillis) { timer.scheduleRepeating(periodMillis); }
public void stop() { timer.cancel(); }
}
In the lab the poller ran every 150 ms, a second client call lend(3) changed the revision, and the poller noticed on its next tick:
== 8 poller sees lend(3)
>> ...|revision|1|2|3|4|0|
<< 200 application/json;charset=utf-8 //OK[0,[],0,7]
>> ...|lend|J|1|2|3|4|1|5|D|
<< 200 application/json;charset=utf-8 //OK[1,[],0,7]
lend(3) -> copies left 1
>> ...|revision|1|2|3|4|0|
<< 200 application/json;charset=utf-8 //OK[1,[],0,7]
poller: revision changed to 1
Polling costs one request per tick per client, so keep the period long, make the polled call cheap, stop the timer when the view is detached (Timer.cancel()), and add a back-off after failures.
Older books emulate push with a servlet that blocks in a Thread.sleep loop until data is ready.
That design ties up a container thread per browser and is replaced today by WebSockets or Server-Sent Events, which a GWT client reaches through Elemental2 or JsInterop; those options are compared on the RequestBuilder, JSON and XML page and on the communication overview page linked at the top.
The timer API is described on Delayed logic and Scheduler.
What travels on the wire
The payload is a text format that GWT designed for compactness, not for humans, and it is an implementation detail of the toolkit.
Reading it once explains most error messages, and the lab captured real samples through a custom RpcRequestBuilder (see ServiceDefTarget and RpcRequestBuilder) and replayed them with curl.
The request
POST /bookshelf/catalog HTTP/1.1
Content-Type: text/x-gwt-rpc; charset=UTF-8
X-GWT-Permutation: 5F59F36FB51BE2BFE4844F629D22FF5C
X-GWT-Module-Base: http://localhost:8932/bookshelf/
7|0|5|http://localhost:8932/bookshelf/|4F134919A31D9111F950CD21A4354BB3|com.example.bookshelf.shared.CatalogService|getBooks|I|1|2|3|4|2|5|5|0|2|
The body is a list of tokens separated by |:
| Tokens | Meaning |
|---|---|
|
Stream version. The server accepts versions 5 to 8; anything else is answered with an |
|
Flags. |
|
Number of strings in the table that follows. |
the five strings |
The module base URL, the strong name of the serialisation policy, the service interface, the method name, and the parameter type |
|
Indexes into the string table: module base, policy, interface, method. |
|
Number of parameters. |
|
The type of each parameter, again as table indexes (both are |
|
The argument values: |
The headers matter as much as the body.
Content-Type: text/x-gwt-rpc; charset=UTF-8 is checked by the servlet.
X-GWT-Permutation is the strong name of the permutation (the compiled JavaScript file) that made the call, and the servlet refuses a request without it.
The module base string in the body tells the servlet where the policy file is, and X-GWT-Module-Base carries the same address for getRequestModuleBasePath().
A call with an object argument is longer because the table also lists the types and the strings of the object.
This is the saveBook request of the lab, truncated:
7|0|10|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|saveBook|com.example.bookshelf.shared.Book/2062416652|Andrew Hunt|com.example.bookshelf.shared.Format/576054756|com.example.bookshelf.shared.Isbn/1614986744|978-0-00-000001-0|Edited title|1|2|3|4|1|5|5|0|6|2|7|0|B|8|9|10|1999|
Book/2062416652 is the type name followed by the type signature: a hash of the class’s serialisable fields and their types.
The server recomputes it from its own class and refuses the call when it differs (see the pitfalls); that is how an out-of-date browser tab is detected after a deployment that changed Book.
The response
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Disposition: attachment
Content-Length: 464
//OK[25,2001,12,11,7,"C",1,6,1,10,"YvP5WgA",4,3,1999,9,8,7,"B",0,6,0,5,"YvP5WgA",4,3,2,2,1,["com.example.bookshelf.shared.BookPage/3107328605","java.util.ArrayList/4159755760","com.example.bookshelf.shared.Book/2062416652","java.util.Date/3385151746","Andrew Hunt","com.example.bookshelf.shared.Format/576054756","com.example.bookshelf.shared.Isbn/1614986744","978-0-00-000001-0","The Pragmatic Programmer","Joshua Bloch","978-0-00-000002-0","Effective Java"],0,7]
//OK marks a returned value and //EX a thrown, declared exception.
The rest is an array that the client reads from the end, because the server writes the values in reverse order: the version 7 and the flags 0 come last, before them the string table, and before the table the values.
Reading backwards, the sample decodes as follows:
1 type 1 of the table: BookPage
2, 2 type 2 (ArrayList), then its size: 2 elements
3 type 3: the first Book
4, "YvP5WgA" field added: type 4 (java.util.Date) and its time as a base-64 long
5 field author: string 5 = "Andrew Hunt"
0 field copies: 0
6, 0 field format: type 6 (Format), ordinal 0 = PAPERBACK
"B" field id: the long 1, base-64 encoded
7, 8 field isbn: type 7 (Isbn) and string 8 = "978-0-00-000001-0"
9 field title: string 9 = "The Pragmatic Programmer"
1999 field year
... the second Book follows, and the total (25) is the first number of the array
The fields of a class are written in the alphabetical order of their names (FIELD_COMPARATOR in SerializabilityUtil), superclasses first.
Renaming a field therefore changes the order and the type signature.
internalNote, which is transient, does not appear at all: the lab logs internalNote is null=true on the client.
A declared exception comes back as //EX:
//EX["Pn",1,["com.example.bookshelf.shared.BookNotFoundException/1919793663"],0,7]
Here the custom field serializer of BookNotFoundException wrote only the id ("Pn" is the long 999 in base 64) and the type; the message is rebuilt by the constructor on the client (see Custom field serializers).
Two protocol versions and what the client does with them
The server writes version 7 by default.
The system property gwt.rpc.version (read by RPC.getRpcVersion() on every response) can raise it to 8.
The client reader accepts versions 5 to 8; for versions before 8 it evaluates the payload with the JavaScript eval, for version 8 it parses it with JsonUtils.safeEval, which in 2.13.1 calls JSON.parse.
That difference is invisible until the page has a Content Security Policy without 'unsafe-eval'.
With script-src 'self' 'unsafe-inline' and the default version 7, every call of the lab failed in the browser:
<< 200 application/json;charset=utf-8 //OK[25,2001,12,11,7,"C",1,6,1,...
onFailure com.google.gwt.core.client.JavaScriptException: (EvalError) : Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script: script-src 'self' 'unsafe-inline'".
The same lab with the server started with -Dgwt.rpc.version=8 gave the same results as without a policy (every answer ends ,0,8] instead of ,0,7]), so version 8 is the way to keep 'unsafe-eval' out of the policy.
The 2.12.0 release notes mention CSP workarounds for GWT-RPC payloads without saying what they are; the property is what the 2.13.1 sources and the lab show, but it is not described on the Developer Guide pages, so verify it on your own build.
The 'unsafe-inline' in that policy was needed by the GWT bootstrap of the lab. The lab used the default obfuscated output, for which the xsiframe linker installs the permutation code inline through script.text, so a policy with only script-src 'self' stopped the module from starting with "Executing inline script violates the following Content Security Policy directive". The same policy lets a module start when it is compiled with -style PRETTY or linked with direct_install or sso; the Linkers and bootstrap page has the measured matrix of output styles and policies, and the SafeHtml and XSS page the policy as a whole.
Eliding type names
Inheriting com.google.gwt.user.RemoteServiceObfuscateTypeNames sets the configuration property gwt.elideTypeNamesFromRPC to true.
The payload then replaces the service name and the type names with short ids, and the flags become 1:
>> 7|1|5|http://localhost:8932/bookshelf/|032A2D0ED58CAB31526FBD4FD26CC63A|_|getBooks|I|1|2|3|4|2|5|5|0|2|
<< 200 application/json;charset=utf-8 //OK[25,2001,12,11,7,"C",1,6,1,10,"YvP5WgA",4,3,1999,9,8,7,"B",0,6,0,5,"YvP5WgA",4,3,2,2,1,["4","l","2","m","Andrew Hunt","5","6","978-0-00-000001-0","The Pragmatic Programmer","Joshua Bloch","978-0-00-000002-0","Effective Java"],1,7]
It makes the payload smaller and hides class names, and it requires the server to use the policy file (the servlet answers with an IncompatibleRemoteServiceException if the policy does not implement TypeNameObfuscator).
The module file itself warns that the way elision is switched on is likely to change and that the configuration line should not simply be copied, so treat it as an optimisation to try, not as a security feature.
The lab passed all its scenarios with it (the same six intended failures).
Replaying a call without a browser
Nothing in the format is secret, and GWT-RPC is not authentication. A script can replay a captured call as long as it sends the content type and a permutation header:
curl -s -i -X POST http://localhost:8932/bookshelf/catalog \
-H 'Content-Type: text/x-gwt-rpc; charset=utf-8' \
-H 'X-GWT-Permutation: 5F59F36FB51BE2BFE4844F629D22FF5C' \
-H 'X-GWT-Module-Base: http://localhost:8932/bookshelf/' \
--data '7|0|5|http://localhost:8932/bookshelf/|4F134919A31D9111F950CD21A4354BB3|com.example.bookshelf.shared.CatalogService|getBooks|I|1|2|3|4|2|5|5|0|2|'
The answer was the 200 and the //OK[...] array shown above.
Without the X-GWT-Permutation header the answer was 500 with "The call failed on the server; see server log for details", and the container log held java.lang.SecurityException: Blocked request without GWT permutation header (XSRF attack?).
With a different Content-Type it was also 500, and the log held jakarta.servlet.ServletException: Content-Type was 'application/json'. Expected 'text/x-gwt-rpc'.
A GET of the same URL gave 405 Method Not Allowed.
The permutation check is a weak CSRF defence: a cross-site form cannot set a custom header, and a cross-origin fetch with one needs a CORS preflight that the server must allow.
It is not authentication and it does not replace the token mechanism described in Security of the RPC channel.
Serialisable types
GWT’s idea of "serialisable" is its own, smaller than Java’s. It is decided at compile time from the types in the service interface, and enforced at run time by the policy file. A type may appear in a service method (as a parameter, a return type, a declared exception, or reachable from any of them through its fields) when at least one of the following holds, which is the list of the Developer Guide checked against the generator:
-
It is a primitive,
String,Date(and thejava.sqlsubclasses, which the policy lists), or a primitive wrapper. -
It is an enum. Only the ordinal travels; the fields of the constants are not sent, and an enum cannot have a custom field serializer.
-
It is an array of serialisable types.
-
It is a user-defined class that is assignable to
IsSerializableorjava.io.Serializable(directly or through a superclass), whose non-final, non-transient instance fields are themselves serialisable, and that has a no-argument constructor of any visibility (or no constructor at all). -
It has at least one serialisable subclass (the compiler then includes the subclasses it can see).
-
It has a custom field serializer (see Custom field serializers).
java.io.Serializable has been accepted since GWT 1.4, as the Developer Guide’s FAQ explains, on the condition that the policy file is deployed.
IsSerializable, which older material requires, is still accepted and is equivalent.
The compiler classifies the problems it finds, and most of them are not fatal for the whole build but exclude the type. The table lists what each construct did when it was put in a probe service and compiled with 2.13.1.
| Shape | Result | Message and remedy |
|---|---|---|
Class with a final field and only a constructor with arguments |
Error |
|
A Java |
Error |
|
A field of type |
Error |
|
A class that is not |
Error |
|
An interface with no serialisable implementation |
Error |
|
A non-static nested class |
Excluded |
The generator source says it "is nested but not static; it will be excluded from the set of serializable types". Declare nested DTOs |
A final instance field |
Warning |
|
A raw |
Warning, and larger output |
|
A field of type |
Error, before RPC |
|
The module property rpc.XserializeFinalFields (values false and true, default false) turns on experimental serialisation of final fields.
The module file declares it experimental and warns that it can change or disappear in any release; it was not used in the lab.
Transient fields and what stays on the server
transient is honoured in both directions, and the Bookshelf relies on it for Book.internalNote.
GWT also accepts any annotation whose simple name is GwtTransient, with com.google.gwt.user.client.rpc.GwtTransient as the shipped one.
It is meant for types shared with other serialisation systems that must keep the field in their own serialisation, because it means the same as transient only for GWT.
public class BookSummary implements Serializable {
private transient String internalNote; // Java serialisation and GWT-RPC both skip it
@GwtTransient private String cachedText; // only GWT-RPC skips it (Java serialisation keeps it)
}
GwtTransient is documented in the Javadoc; only transient was compiled in the lab.
Collections, maps and polymorphism
Parameterise every collection and prefer concrete types in the service signature.
The Developer Guide gives two reasons: smaller JavaScript, because the compiler only includes the serialisers of the types you declare, and protection against denial-of-service attacks, because the server verifies each deserialised value against the generic parameter of the method signature.
ArrayList<Review> and HashMap<Format, Integer> crossed the wire in the lab; the policy file lists only ArrayList, HashMap and LinkedHashMap for them, plus Integer and its superclass Number.
== 13 copiesByFormat(): HashMap with enum keys
>> 7|0|4|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|copiesByFormat|1|2|3|4|0|
<< 200 application/json;charset=utf-8 //OK[-3,1,2,11,3,2,2,12,3,0,2,3,1,["java.util.HashMap/1797211028","com.example.bookshelf.shared.Format/576054756","java.lang.Integer/3438268394"],0,7]
OK PAPERBACK -> 12
OK HARDCOVER -> 12
OK EBOOK -> 11
A method that returns List<Book> is legal, but the compiler then has to include a serialiser for every List implementation it can see.
Returning ArrayList<Book> limits the code, and the wire format shows the same type either way (java.util.ArrayList/4159755760).
If the server returns an instance of a subclass that the client never saw, the server refuses to serialise it (see Serialisation policy files).
A parameter whose runtime type is not the declared type is rejected by the server, because the type check of the deserialiser (SerializedTypeViolationException) compares the two.
Sending an ArrayList where the signature says Book got this answer from the servlet:
//EX[2,1,["com.google.gwt.user.client.rpc.IncompatibleRemoteServiceException/3936916533","Attempt to deserialize an object of type class java.util.ArrayList when an object of type class com.example.bookshelf.shared.Book is expected"],0,7]
The SerializedTypeViolationException is thrown while decoding and surfaces to the browser as an IncompatibleRemoteServiceException carrying its message.
Blocking a type
The configuration property rpc.blacklist holds regular expressions applied to type names, each optionally prefixed with - (the default, block) or + (allow again); the last matching entry wins.
<extend-configuration-property name="rpc.blacklist" value="com\.example\.probe\.client\.Types\.HasFinal" />
The probe with this line failed for the blocked type with the same message as a non-serialisable type: 'com.example.probe.client.Types.HasFinal' has no instantiable subtypes.
Use the property to keep a type out of RPC when a broad base type (a Serializable interface, a base entity) would otherwise pull it in.
Serialisation policy files
At compile time the proxy generator writes one serialisation policy file per service interface into the module output directory.
The file name is the policy’s strong name followed by .gwt.rpc.
The strong name is the second string of every request, so the server finds the file with the module base path and that name.
@FinalFields, false
com.example.bookshelf.shared.AccessDeniedException, true, true, false, false, com.example.bookshelf.shared.AccessDeniedException/35684112, 35684112
com.example.bookshelf.shared.Book, true, true, true, true, com.example.bookshelf.shared.Book/2062416652, 2062416652
com.example.bookshelf.shared.BookNotFoundException, true, true, false, false, com.example.bookshelf.shared.BookNotFoundException/1919793663, 1919793663
com.example.bookshelf.shared.BookPage, true, true, false, false, com.example.bookshelf.shared.BookPage/3107328605, 3107328605
com.example.bookshelf.shared.CatalogService, false, false, false, false, _, 4045853246
com.example.bookshelf.shared.Format, true, true, true, true, com.example.bookshelf.shared.Format/576054756, 576054756
com.example.bookshelf.shared.Isbn, true, true, true, true, com.example.bookshelf.shared.Isbn/1614986744, 1614986744
com.example.bookshelf.shared.Review, true, true, false, false, com.example.bookshelf.shared.Review/2693756894, 2693756894
...
com.google.gwt.user.client.rpc.XsrfToken, false, false, true, true, com.google.gwt.user.client.rpc.XsrfToken/4254043109, 4254043109
java.lang.Exception, true, false, true, false, java.lang.Exception/1920171873, 1920171873
...
java.util.ArrayList, true, true, false, false, java.util.ArrayList/4159755760, 4159755760
java.util.Date, true, true, true, true, java.util.Date/3385151746, 3385151746
java.util.HashMap, true, true, false, false, java.util.HashMap/1797211028, 1797211028
| Column | Meaning |
|---|---|
1 |
Binary class name |
2 and 3 |
Fields may be serialised (sent to the browser), and the type may be instantiated when serialising |
4 and 5 |
Fields may be deserialised (received from the browser), and the type may be instantiated when deserialising |
6 |
Type id: the name with the signature hash ( |
7 |
The signature hash itself |
The sample reads naturally: Book is true in all four columns because it is both sent and received; BookPage and the exceptions only go to the browser; XsrfToken only comes from it.
A line @ClientFields,<class>,<fields...> appears for classes configured as "enhanced" (see JPA, JDO and DTOs: the 2.10.1 and 2.11 change), and @FinalFields, false records the rpc.XserializeFinalFields setting.
The compiler also writes rpcPolicyManifest/manifest.txt with one line per service, into the deploy directory of the module (bookshelf-client/target/gwt/deploy/bookshelf/rpcPolicyManifest/manifest.txt in the Maven build):
# Module bookshelf
# RPC service class, partial path of RPC policy file
com.example.bookshelf.shared.NoPathService, D310D7D4714B06BEF59A04DA01C61505.gwt.rpc
com.example.bookshelf.shared.CatalogService, 4F134919A31D9111F950CD21A4354BB3.gwt.rpc
Build scripts that copy the policies by hand, and servers that do not share a web application with the client, use the manifest to find them.
How the servlet finds the policy
RemoteServiceServlet.getSerializationPolicy runs once per module base URL and strong name and caches the result:
-
It strips the context path from the module base path and opens
<module path><strong name>.gwt.rpcwithServletContext.getResourceAsStream. The module path must be inside the servlet’s own web application. -
If the file is not found, and the system property
gwt.codeserver.portis set, it downloads the policy from the Super Dev Mode code server (see Policies in Super Dev Mode). -
If nothing is found, it logs a warning and falls back to the legacy 1.3.3 policy, which accepts only
IsSerializabletypes.
The fallback is the source of the most confusing failure of GWT-RPC.
It was reproduced by deleting the *.gwt.rpc files from the deployed module directory of a running lab server before the first call:
[INFO] catalogServlet: ERROR: The serialization policy file '/bookshelf/4F134919A31D9111F950CD21A4354BB3.gwt.rpc' was not found; did you forget to include it in this deployment?
[INFO] catalogServlet: WARNING: Failed to get the SerializationPolicy '4F13...' for module 'http://localhost:8932/bookshelf/'; a legacy, 1.3.3 compatible, serialization policy will be used. You may experience SerializationExceptions as a result.
[WARNING] Exception while dispatching incoming RPC call
com.google.gwt.user.client.rpc.SerializationException: Type 'com.example.bookshelf.shared.BookPage' was not assignable to 'com.google.gwt.user.client.rpc.IsSerializable' and did not have a custom field serializer.For security purposes, this type will not be serialized.: instance = com.example.bookshelf.shared.BookPage@79fa53da
The browser got 500 Server Error The call failed on the server; see server log for details.
describeRequest(), which returns a String, kept working, and the declared exceptions kept working, because String and the exceptions with custom serialisers do not need the policy.
The cause is always the deployment: the files were not copied, the servlet is in another web application, a proxy rewrites the path, or the client was compiled again and the server still serves the old module directory.
A type that is in the policy of the module but is not the declared or a known subtype is refused differently.
getBook(13) in the lab returned a server-only subclass LegacyBook extends Book, which the compiler never saw:
[WARNING] Exception while dispatching incoming RPC call
com.google.gwt.user.client.rpc.SerializationException: Type 'com.example.bookshelf.server.LegacyBook' was not included in the set of types which can be serialized by this SerializationPolicy or its Class object could not be loaded. For security purposes, this type will not be serialized.: instance = com.example.bookshelf.server.LegacyBook@72c810bb
Return a new Book(...) copy, never the subclass (the same rule makes JPA entities and proxies of Hibernate unusable as results, see JPA, JDO and DTOs: the 2.10.1 and 2.11 change).
Policies in Super Dev Mode
In development the Java server and the code server are separate processes. Two mechanisms exist, and the lab ran both.
The first one copies the policies.
When the Maven plugin is configured with a launcherDir (the modular-webapp archetype does this), the code server copies each policy file next to the bootstrap script in that directory, and the Jetty profile env-dev adds that directory to its resource bases (see the archetype’s bookshelf-server/pom.xml).
A comment in LauncherDir.java explains the purpose: the copies let the RemoteServiceServlet subclass find the policies in the module directory.
In the lab the first page load compiled the module on demand and the calls worked.
The second one downloads them.
Start the servlet container with -Dgwt.codeserver.port=9932 (the code server’s port) and RemoteServiceServlet.getCodeServerPolicyUrl returns http://localhost:<port>/policies/<strong name>.gwt.rpc.
With the copies deleted, the first call logged:
[INFO] catalogServlet: ERROR: The serialization policy file '/bookshelf/4F134919A31D9111F950CD21A4354BB3.gwt.rpc' was not found; did you forget to include it in this deployment?
[INFO] catalogServlet: Downloaded serialization policy from http://localhost:9932/policies/4F134919A31D9111F950CD21A4354BB3.gwt.rpc
and the lab then passed.
The Javadoc of getCodeServerPolicyUrl warns what this is: a policy fetched from a server that is not trusted "could be used as a way to disable security checks", so use the property on a developer machine only, never in a deployed container.
The default loader is a plain java.net.URL client with no authentication and 5-second connect and read timeouts.
See Development mode and debugging for the code server itself.
Custom field serializers
A custom field serializer (CFS) replaces the automatic, reflection-based serialisation of one class with code you write.
Use one when the class has no no-argument constructor (an immutable value type), when a more compact wire representation is worth the code, for a Java record, for a type from a library that you cannot change, and, as the next sections show, for your own exception classes on Java 17 and later.
Most of the JRE collection classes in GWT have one, as do Date, the wrapper types and StackTraceElement.
The rules, from the Developer Guide and checked against CustomFieldSerializerValidator:
-
The class is named after the class it serialises with the suffix
_CustomFieldSerializerand sits in the same package (Isbn_CustomFieldSerializernext toIsbn). Types of the JRE can be served fromcom.google.gwt.user.client.rpc.core.<package>. -
It must be visible to the GWT compiler (in the source path), because the generated client code calls it. A serialiser in a server-only package is never used by the browser.
-
The compiler requires
public staticmethods:void serialize(SerializationStreamWriter, T),void deserialize(SerializationStreamReader, T), andT instantiate(SerializationStreamReader)when the type is not default-instantiable. -
The server prefers an instance: a CFS that also
extends CustomFieldSerializer<T>and overridesserializeInstance,deserializeInstance, and (for custom instantiation)hasCustomInstantiateInstancereturningtrueplusinstantiateInstance. Every JRE serialiser of GWT does both and delegates the instance methods to the static ones. -
The compiler rejects enums with a serializer: "Enumerated types cannot have custom field serializers."
The Developer Guide only says that a CFS should extend CustomFieldSerializer.
A class that does that but has no static methods does not compile.
The first version of the Bookshelf’s Isbn_CustomFieldSerializer had only the instance methods, and the compiler answered:
[ERROR] Custom Field Serializer 'com.example.bookshelf.shared.Isbn_CustomFieldSerializer' does not define a deserialize method: 'public static void deserialize(com.google.gwt.user.client.rpc.SerializationStreamReader reader,com.example.bookshelf.shared.Isbn instance)' (reached via com.example.bookshelf.shared.Book)
[ERROR] Custom Field Serializer 'com.example.bookshelf.shared.Isbn_CustomFieldSerializer' does not define a serialize method: 'public static void serialize(com.google.gwt.user.client.rpc.SerializationStreamWriter writer,com.example.bookshelf.shared.Isbn instance)' (reached via com.example.bookshelf.shared.Book)
[ERROR] Custom Field Serializer 'com.example.bookshelf.shared.Isbn_CustomFieldSerializer' does not define an instantiate method: 'public static com.example.bookshelf.shared.Isbn instantiate(com.google.gwt.user.client.rpc.SerializationStreamReader reader)'; but 'com.example.bookshelf.shared.Isbn' is not default instantiable (reached via com.example.bookshelf.shared.Book)
[ERROR] com.example.bookshelf.shared.Isbn has no available instantiable subtypes. (reached via com.example.bookshelf.shared.Book)
[ERROR] subtype Custom Field Serializer 'com.example.bookshelf.shared.Isbn_CustomFieldSerializer' does not define a deserialize method: ...
[ERROR] Errors in 'com/example/bookshelf/client/CatalogGateway.java'
[ERROR] Line 14: Failed to resolve 'com.example.bookshelf.shared.CatalogService' via deferred binding
The corrected class, in the shared module:
public final class Isbn {
private final String value;
private Isbn(String value) { this.value = value; }
public static Isbn of(String value) {
if (value == null || value.length() > 20 || !value.matches("[0-9X-]+")) {
throw new IllegalArgumentException("Invalid ISBN");
}
return new Isbn(value);
}
public String value() { return value; }
// equals, hashCode, toString omitted
}
public final class Isbn_CustomFieldSerializer extends CustomFieldSerializer<Isbn> {
public static void deserialize(SerializationStreamReader reader, Isbn instance) {
// Everything was read by instantiate(): there is no state left to restore.
}
public static Isbn instantiate(SerializationStreamReader reader) throws SerializationException {
return Isbn.of(reader.readString());
}
public static void serialize(SerializationStreamWriter writer, Isbn instance) throws SerializationException {
writer.writeString(instance.value());
}
@Override
public void deserializeInstance(SerializationStreamReader reader, Isbn instance) {
deserialize(reader, instance);
}
@Override
public boolean hasCustomInstantiateInstance() {
return true;
}
@Override
public Isbn instantiateInstance(SerializationStreamReader reader) throws SerializationException {
return instantiate(reader);
}
@Override
public void serializeInstance(SerializationStreamWriter writer, Isbn instance) throws SerializationException {
serialize(writer, instance);
}
}
instantiate runs Isbn.of(...), so the validation of the value type also runs on the server for every ISBN that arrives from a browser.
That is a useful property: the constructor of a value type is the natural place to reject malformed input, and a CFS makes the framework use it.
In the wire sample the ISBN is the pair 7, 8 (the type Isbn/1614986744 and the string 978-0-00-000001-0).
Records
A record needs the same treatment, because its components are final fields and it has no default constructor.
The compiler accepts records in shared source (the Bookshelf compiles at -sourceLevel 17), but RPC fails without a serializer (see the probe table above).
public record Review(long bookId, int stars, String comment) implements Serializable {}
public final class Review_CustomFieldSerializer extends CustomFieldSerializer<Review> {
public static void deserialize(SerializationStreamReader reader, Review instance) { }
public static Review instantiate(SerializationStreamReader reader) throws SerializationException {
return new Review(reader.readLong(), reader.readInt(), reader.readString());
}
public static void serialize(SerializationStreamWriter writer, Review instance) throws SerializationException {
writer.writeLong(instance.bookId());
writer.writeInt(instance.stars());
writer.writeString(instance.comment());
}
// the four instance methods delegate to the static ones, as in Isbn_CustomFieldSerializer
}
The lab called reviews(2), which returns ArrayList<Review>:
== 12 reviews(2): a record via a custom field serializer, ArrayList<Review>
>> 7|0|5|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|reviews|J|1|2|3|4|1|5|C|
<< 200 application/json;charset=utf-8 //OK[4,3,"C",2,3,5,"C",2,2,1,["java.util.ArrayList/4159755760","com.example.bookshelf.shared.Review/2693756894","Still the best introduction","Dated in places"],0,7]
OK review 2 5 Still the best introduction
OK review 2 3 Dated in places
Notice that the type of Review is listed in the string table once, and each record is the triple the serializer wrote (bookId as a base-64 long, stars, the comment string).
The policy file has Review ... true, true, false, false: the server may send reviews but not accept them.
A type-checked server-side serializer
A server must deserialise whatever bytes a browser sends, before the service method can reject them.
That makes deserialisers a target for denial-of-service payloads (very large or deeply nested values).
Since GWT 2.4 a CFS can have a server-only twin, <Name>_ServerCustomFieldSerializer extends ServerCustomFieldSerializer<T>, whose methods receive the expected generic parameter types and are supposed to read nested objects with the type-checking readObject overloads.
The built-in HashMap_ServerCustomFieldSerializer is the reference.
The lookup of the name replaces the first occurrence of the text client in the fully-qualified class name with server.
com.example.bookshelf.shared.Isbn has no client in it, so its twin must be named com.example.bookshelf.shared.Isbn_ServerCustomFieldSerializer, in the same package as the shared class.
That is possible without GWT seeing it: put the class in the server module (a split package on the class path), where the GWT source path never reaches.
package com.example.bookshelf.shared; // the shared package, but in the server module
public final class Isbn_ServerCustomFieldSerializer extends ServerCustomFieldSerializer<Isbn> {
@Override
public boolean hasCustomInstantiateInstance() {
return true;
}
@Override
public Isbn instantiateInstance(ServerSerializationStreamReader reader, Type[] expectedParameterTypes,
DequeMap<TypeVariable<?>, Type> resolvedTypes) throws SerializationException {
return Isbn.of(reader.readString());
}
@Override
public void deserializeInstance(ServerSerializationStreamReader reader, Isbn instance,
Type[] expectedParameterTypes, DequeMap<TypeVariable<?>, Type> resolvedTypes) { }
@Override
public void deserializeInstance(SerializationStreamReader reader, Isbn instance) { }
@Override
public void serializeInstance(SerializationStreamWriter writer, Isbn instance) throws SerializationException {
writer.writeString(instance.value());
}
}
With a temporary System.out.println in instantiateInstance, the container log showed the twin used when the saveBook request (which carries a Book with an ISBN) arrived.
Isbn holds only a string, so there is nothing to type-check here; the example shows where the class goes.
A twin that reads collections or other objects must pass the expected type to readObject, as the built-in ones do; getting that wrong is what leaves the server exposed.
The unit that does the type check for the normal path is ServerSerializationStreamReader, which throws SerializedTypeViolationException when a value is not of the expected type (see the table of exceptions).
Exceptions: everything that can reach onFailure
Every failure of a call, wherever it happens, arrives in AsyncCallback.onFailure(Throwable), except one that is thrown synchronously by the proxy (see the table).
The flow chart sorts the cases by where they occur.
The table lists every exception that appeared in the lab or in the sources, where it is created, and what to do.
| Exception | Where | When | What the application should do |
|---|---|---|---|
A declared checked exception ( |
server, then client |
The service method throws it and its |
Handle it as part of the API (show "not found"). Give it a custom field serializer on Java 17 and later (see Java 17 and later: exceptions that cannot be serialised). |
An undeclared exception ( |
server |
The service method throws it. The server wraps it in |
Fix the cause or declare and handle it. Never rely on the message reaching the browser; this is deliberate, because it hides internals. |
|
client |
The HTTP status is not 200. Methods: |
Branch on the status code. Show your own text: |
|
client |
Three cases: a 200 answer that is neither |
An HTML body means a proxy, a gateway or an SSO page answered instead of the servlet; ask the user to sign in again. |
|
server, then client |
The client and the server disagree: unknown method, wrong parameter count, a changed type signature, an unknown RPC version, a service interface the servlet does not implement, or a response that the client cannot deserialise. Arrives as |
The page is out of date. Get the application into a state where a reload is safe (save what you can) and reload. See the messages in the pitfalls table. |
|
server (and custom serialisers) |
The server cannot serialise a result (type not in the policy) or a custom serializer fails. It is logged and the browser sees a |
Fix the policy deployment or the type (see Serialisation policy files). Throw it from your own CFS to signal bad data. |
|
server |
A value in the request is not of the type the method signature expects: a tampered request. The servlet turns it into an |
Log it as a possible attack. Nothing to handle in the client. |
|
server, then client |
The XSRF token is missing or wrong. If an |
Fetch a new token and retry once (see Security of the RPC channel). |
|
client |
Thrown by the Async call itself, before anything is sent: the service has no |
A programming error. Fix the mapping. |
|
client |
A timeout set on the request builder expired. |
Retry for a read call, or tell the user that the result is uncertain for a write. |
|
server |
|
Look at its cause in the log. |
|
server |
"Blocked request without GWT permutation header (XSRF attack?)", or "Blocked attempt to access inaccessible method … this is either misconfiguration or a hack attempt". Logged; the browser gets a |
Check that the caller is a GWT client, or disable |
|
server |
"Content-Type was 'application/json'. Expected 'text/x-gwt-rpc'." when the body is read. |
The request did not come from a GWT proxy. |
|
client and server |
Deprecated since GWT 1.5; an |
Extend |
Design the exceptions that cross the wire like an API.
Keep them small, built from serialisable fields only, and without messages that reveal paths, SQL or stack traces.
In the captured //EX payloads of the Bookshelf the server’s stack trace never appeared; the payload held the type and the message (or just the id, with the custom serializer).
A handler that follows the table is in the presenter above (messageFor), and it is covered by JUnit tests that feed it each exception.
Java 17 and later: exceptions that cannot be serialised
GWT-RPC serialises an exception through reflection, like any other class, and every exception inherits the private fields of java.lang.Throwable.
Since JDK 17 the module system denies reflective access to the private fields of java.base classes from unnamed modules unless the JVM is started with --add-opens.
The consequence is a failure that is easy to hit and hard to diagnose.
This is open upstream as gwtproject/gwt issue 9793 ("Serialization fails with Unable to make field private java.lang.String java.lang.Throwable.detailMessage accessible", opened on 2023-01-09 against GWT 2.10.0 on Java 17, still open when this page was checked on 2026-10-09, with three comments and no fix).
Reproducing it
The Bookshelf lab was started on JDK 21 with no extra JVM options, and the first versions of its exceptions had no custom serializer.
The call getBook(999) should answer with the declared BookNotFoundException:
== 4 getBook(999): declared checked exception
>> 7|0|5|http://localhost:8932/bookshelf/|D981D547C35841434CBFC68CFB9C1AEA|com.example.bookshelf.shared.CatalogService|getBook|J|1|2|3|4|1|5|Pn|
<< 500 text/plain The call failed on the server; see server log for details
onFailure com.google.gwt.user.client.rpc.StatusCodeException: 500 Server Error The call failed on the server; see server log for details [status=500]
The browser never sees the exception. The container log has the cause, and the top of the trace points into the serialiser:
[WARNING] Exception while dispatching incoming RPC call
java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String java.lang.Throwable.detailMessage accessible: module java.base does not "opens java.lang" to unnamed module @35d7b746
at java.lang.reflect.AccessibleObject.throwInaccessibleObjectException (AccessibleObject.java:391)
at java.lang.reflect.Field.setAccessible (Field.java:177)
at com.google.gwt.user.server.rpc.impl.ServerSerializationStreamWriter.serializeClass (ServerSerializationStreamWriter.java:784)
...
at com.google.gwt.user.server.rpc.jakarta.RPC.encodeResponse (RPC.java:645)
at com.google.gwt.user.server.rpc.jakarta.RPC.encodeResponseForFailure (RPC.java:420)
at com.google.gwt.user.server.rpc.jakarta.RPC.invokeAndEncodeResponse (RPC.java:606)
at com.google.gwt.user.server.rpc.jakarta.RemoteServiceServlet.processCall (RemoteServiceServlet.java:350)
The identifier after @ changes from run to run.
Every exception that has to be serialised fails in the same way: a declared exception thrown by the service (BookNotFoundException, AccessDeniedException), and the exceptions of GWT’s own protocol.
The latter are the worst, because the mechanisms that tell the client what is wrong stop working:
-
IncompatibleRemoteServiceException(the "reload the page" signal after a deployment) is never delivered. Acurlrequest for an unknown method got500 ... The call failed on the server, where the same request on a JVM with the option got the//EX[...IncompatibleRemoteServiceException...]shown in the pitfalls table. The container log shows the incompatible-exception warning followed by the sameInaccessibleObjectException. -
RpcTokenException(a failed XSRF check) hits the same problem; Group 1 of the scratch application saw it the first time a call without a token was made. -
The StockWatcher tutorial of the Developer Guide teaches
DelistedException extends Exception implements Serializablewith a field, which has the same shape asBookNotFoundException, so following the tutorial on a current JDK ends in the generic500.
Three ways out
1. Open the package for the servlet container’s JVM. The option that GWT’s own exceptions need:
# Maven Jetty plugin: the container runs inside the Maven JVM
MAVEN_OPTS='--add-opens java.base/java.lang=ALL-UNNAMED' mvn -B -pl bookshelf-server -am verify
# any container started by a script or an executable JAR
java --add-opens java.base/java.lang=ALL-UNNAMED -jar bookshelf.jar
With the option, the same getBook(999) call returned the exception, with the type, the id and the message written by reflection:
<< 200 application/json;charset=utf-8 //EX[2,"Pn",1,["com.example.bookshelf.shared.BookNotFoundException/2446222147","No book with id 999"],0,7]
onFailure com.example.bookshelf.shared.BookNotFoundException: No book with id 999
id=999
The option has to reach the JVM that hosts the servlet container: MAVEN_OPTS for the Maven Jetty plugin was the form that was run.
Tomcat reads CATALINA_OPTS, and an application server has its own JVM option file; those forms were not run.
Note that the module is java.base, package java.lang, target ALL-UNNAMED: the exceptions of a library (java.util, for example) would need their own package.
2. Give each of your own exceptions a custom field serializer.
A CFS replaces the reflective walk over the class and its superclasses, so the private fields of Throwable are never touched.
The serializer of BookNotFoundException writes the id and the constructor rebuilds the message:
public final class BookNotFoundException_CustomFieldSerializer
extends CustomFieldSerializer<BookNotFoundException> {
public static void deserialize(SerializationStreamReader reader, BookNotFoundException instance) {
// Nothing left to read: instantiate() consumed the id.
}
public static BookNotFoundException instantiate(SerializationStreamReader reader) throws SerializationException {
return new BookNotFoundException(reader.readLong());
}
public static void serialize(SerializationStreamWriter writer, BookNotFoundException instance)
throws SerializationException {
writer.writeLong(instance.getId());
}
// deserializeInstance, hasCustomInstantiateInstance, instantiateInstance and serializeInstance
// delegate to the three static methods, as in Isbn_CustomFieldSerializer
}
On JDK 21 with no --add-opens, the lab then returned the declared exceptions, with a smaller payload (no message, no cause):
<< 200 application/json;charset=utf-8 //EX["Pn",1,["com.example.bookshelf.shared.BookNotFoundException/1919793663"],0,7]
onFailure com.example.bookshelf.shared.BookNotFoundException: No book with id 999
id=999
<< 200 application/json;charset=utf-8 //EX[1,["com.example.bookshelf.shared.AccessDeniedException/35684112"],0,7]
onFailure com.example.bookshelf.shared.AccessDeniedException: Access denied (declared)
This is the better answer for your exceptions: it needs no JVM option, it never sends a stack trace, and it sends only what you choose.
It cannot help with IncompatibleRemoteServiceException and RpcTokenException, which are GWT classes, so the first option is still needed for them (or accept that they appear as 500).
3. Avoid exceptions for expected outcomes.
Return a result object with a status (Found, NotFound, Denied) for outcomes that are part of normal operation, and keep exceptions for the unexpected.
That also makes the contract visible in the type of the result, and it is independent of the JVM options.
Both 1 and 2 are workarounds for a missing feature of the toolkit, so re-test them when a newer GWT is adopted, and keep the JVM option in the same build file as the server’s start-up so that it is not forgotten.
ServiceDefTarget and RpcRequestBuilder
The generated proxy implements ServiceDefTarget, the interface that configures where and how it calls.
Cast the Async proxy to it:
| Method | Use |
|---|---|
|
Sets the URL. |
|
The current URL, or |
|
Installs a builder that configures every HTTP request of this proxy. |
|
The strong name of the policy that the proxy was compiled against (the second string of each request). |
setServiceEntryPoint is the old way, and the only way for a service whose interface cannot carry the annotation.
The lab used it for a delegate servlet (ping) and to point a proxy at a wrong path on purpose.
When neither the annotation nor the setter was used, the first call threw before sending anything:
== 10 service without @RemoteServiceRelativePath
entry point is null: true
thrown com.google.gwt.user.client.rpc.ServiceDefTarget$NoServiceEntryPointSpecifiedException: Service implementation URL not specified
RpcRequestBuilder
An RpcRequestBuilder creates and configures the RequestBuilder that carries one RPC.
Its public methods are final and run in a fixed order, create, setContentType, setRequestData, setCallback, setRequestId, finish, and each delegates to a protected doXyz method that you can override:
| Method | Default behaviour |
|---|---|
|
A |
|
Sets the headers |
|
Sets the |
|
Passes the payload to the |
|
Passes the callback. Wrap it to observe responses. |
|
Nothing; it receives the request number. |
The constants CONTENT_TYPE_HEADER, MODULE_BASE_HEADER (X-GWT-Module-Base) and STRONG_NAME_HEADER (X-GWT-Permutation) are public.
The Bookshelf builder adds an application header and a timeout to every call, and logs the wire format (the log lines are lab-only; the header and the timeout are the real purpose):
public class BookshelfRpcRequestBuilder extends RpcRequestBuilder {
private final int timeoutMillis;
public BookshelfRpcRequestBuilder(int timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
@Override
protected void doFinish(RequestBuilder rb) {
super.doFinish(rb); // keeps X-GWT-Permutation and X-GWT-Module-Base
rb.setHeader("X-Bookshelf-Client", "lab");
rb.setTimeoutMillis(timeoutMillis);
}
@Override
protected void doSetRequestData(RequestBuilder rb, String data) {
RpcLab.log(" >> " + data); // lab only: shows the request payload
super.doSetRequestData(rb, data);
}
@Override
protected void doSetCallback(RequestBuilder rb, final RequestCallback callback) {
super.doSetCallback(rb, new RequestCallback() {
@Override
public void onResponseReceived(Request request, Response response) {
RpcLab.log(" << " + response.getStatusCode() + " " + response.getHeader("Content-Type") + " "
+ response.getText()); // lab only: shows the response
callback.onResponseReceived(request, response);
}
@Override
public void onError(Request request, Throwable exception) {
RpcLab.log(" << onError " + exception.getClass().getName() + ": " + exception.getMessage());
callback.onError(request, exception);
}
});
}
}
Source: GWT Javadoc — RpcRequestBuilder
The server saw the header in the describeRequest() call of the lab:
== 2 describeRequest()
<< 200 application/json;charset=utf-8 //OK[1,["method=POST; content-type=text/x-gwt-rpc; charset=UTF-8; module-base-path=/bookshelf/; permutation=5F59F36FB51BE2BFE4844F629D22FF5C; X-Bookshelf-Client=lab"],0,7]
OK method=POST; content-type=text/x-gwt-rpc; charset=UTF-8; module-base-path=/bookshelf/; permutation=5F59F36FB51BE2BFE4844F629D22FF5C; X-Bookshelf-Client=lab
(= is the JavaScript escape of =, which the server applies to every string in a response.)
The timeout of 1000 ms made a slow call fail with an exception that is not wrapped:
== 9 slow(1500) with a 1000 ms timeout
>> 7|0|5|http://localhost:8932/bookshelf/|4F13...|com.example.bookshelf.shared.CatalogService|slow|I|1|2|3|4|1|5|1500|
<< onError com.google.gwt.http.client.RequestTimeoutException: A request timeout has expired after 1000 ms
onFailure com.google.gwt.http.client.RequestTimeoutException: A request timeout has expired after 1000 ms
(This scenario needs real time: the first runs used Chrome’s virtual time budget, in which the 1.5-second sleep finished before the timer fired, so the lab was driven through the DevTools protocol instead.)
Other uses of a custom builder are a correlation id header for logs, a different content-encoding strategy, and the places where credentials are attached when the server is on another origin.
For a cross-origin service the browser sends a CORS preflight, because Content-Type: text/x-gwt-rpc and the X-GWT-* headers are not CORS-safelisted, and the server must answer the preflight with matching Access-Control-Allow-Headers; the general mechanism is on CORS.
An unreachable origin and a blocked cross-origin call both surface as StatusCodeException with status 0 (checked with an unreachable address: 0 followed by two spaces, the empty status text and the empty body).
A successful cross-origin RPC was not run in the lab.
JPA, JDO and DTOs: the 2.10.1 and 2.11 change
Persistence frameworks "enhance" entity classes on the server with extra fields (for example the state of a lazy proxy).
Before 2.10.1, GWT-RPC had a feature for that case: a class annotated with javax.jdo.annotations.PersistenceCapable(detachable=true) or javax.persistence.Entity, or named in the rpc.enhancedClasses property, was an enhanced class.
The server serialised the fields that the client did not know with Java serialisation into one Base64 string, sent it to the browser as an opaque blob, and deserialised it again when the browser sent the object back.
That is a Java deserialisation of data controlled by the client, which is a known class of remote-code-execution vulnerabilities.
Issue gwtproject/gwt#9709 ("Java Deserialization vulnerability in GWT-RPC") reported it. The maintainers' note at the top of the issue lists the mitigation that follows and two follow-up issues, signing the payloads (#9880) and not serialising such classes at all (#9881), which were still waiting for contributors when the note was written.
The 2.11.0 release (2024-01-09) disabled it by default and added a warning when it is in use, and 2.10.1 (2024-01-09) backported the change. The Developer Guide still has a section "Serializing Enhanced Classes" written for GWT 2.0 that does not mention this, so read it as history.
What 2.13.1 does now
The lab marked Book as an enhanced class with a single line in the module file, which is the same effect as an @Entity on the compiler’s class path:
<extend-configuration-property name="rpc.enhancedClasses" value="com.example.bookshelf.shared.Book" />
The compiler warned, twice (once per direction), and wrote a @ClientFields line into the policy:
[WARN] The class com.example.bookshelf.shared.Book has JPA/JDO annotations or is explicitly configured as an enhanced class using the configuration property rpc.enhancedClasses. This makes the server vulnerable to an issue with deserialization of unsafe data. See https://github.com/gwtproject/gwt/issues/9709 for more information.
...
@ClientFields,com.example.bookshelf.shared.Book,id,title,author,year,copies,isbn,format,added
The servlet then refused the whole service.
It rejects the policy and falls back to the legacy one, so every call fails with a 500:
[INFO] catalogServlet: ERROR: Service deserializes enhanced JPA/JDO classes, which is unsafe. Review build logs to see which classes are affected, or set gwt.enhancedClasses.enabled to true to allow using this service. See https://github.com/gwtproject/gwt/issues/9709 for more detail.
[INFO] catalogServlet: WARNING: Failed to get the SerializationPolicy '8FE2...' for module 'http://localhost:8932/bookshelf/'; a legacy, 1.3.3 compatible, serialization policy will be used. You may experience SerializationExceptions as a result.
Starting the JVM with -Dgwt.enhancedClasses.enabled=true (the constant SerializationPolicyLoader.ENABLE_GWT_ENHANCED_CLASSES_PROPERTY) is the explicit opt-in.
With it the lab log carried a different message, WARNING: Service deserializes enhanced JPA/JDO classes, which is unsafe. See https://github.com/gwtproject/gwt/issues/9709 for more detail on the vulnerability that this presents., and the calls worked.
The first response then contained the blob: the Base64 text of a Java serialisation stream (here with zero server-only fields), which starts with the magic number of Java serialisation, rO0AB in Base64:
...,"com.example.bookshelf.shared.Book/2062416652","rO0ABXcEAAAAAA==","java.util.Date/3385151746",...
Two details are easy to miss.
First, the detection in the 2.13.1 sources looks up javax.persistence.Entity and javax.jdo.annotations.PersistenceCapable by name on the compiler’s class path; it has no lookup for the jakarta.persistence annotations, so a Jakarta entity is not treated as enhanced.
That does not make it a good RPC type (see below); it only means the warning does not tell you.
Second, the opt-in is a JVM property of the server, so a build that works on a developer machine can fail in a container that lacks it.
Use data transfer objects
The recommendation of the Developer Guide’s neighbour article, "Using GWT with Hibernate" (2009), still stands and is stronger now: do not send entities.
Entities carry lazy collections and proxies (accessing one raises LazyInitializationException or fails to serialise), reference whole graphs, carry columns the browser must never see, and bind the wire format to the database schema.
The article compares three strategies: hand-written DTOs and copy methods, the Dozer mapper, and the Gilead library that replaces proxies automatically.
This page did not evaluate Dozer or Gilead; the article’s own conclusion is that DTOs are the simple, predictable approach and cost the copy code.
(Its sample DTO also holds an account password; do not copy that. A DTO is a list of what the screen needs, and a password hash is never on it.)
The Bookshelf keeps the entity out of the shared module and maps it with a function that lists the fields the browser may see:
/** Server-only persistence entity: never appears in a RemoteService signature. */
@Entity
public class BookEntity {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String title;
private String author;
private int year;
private int copies;
@OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
private List<LoanEntity> loans = new ArrayList<>();
// getters omitted
}
/** Copies exactly the fields the browser needs, and nothing that is lazy or confidential. */
public final class BookMapper {
private BookMapper() {}
public static Book toDto(BookEntity entity) {
return new Book(entity.getId(), entity.getTitle(), entity.getAuthor(), entity.getYear(), entity.getCopies());
}
}
The classes compiled against jakarta.persistence-api 3.1.0 with javac --release 21; they were not run against a database.
Do the mapping inside the transaction (before lazy associations close), return a new ArrayList<Book>, and keep LoanEntity.memberEmail and its like out of every DTO.
For many entities a mapping generator is less error-prone than hand-written copies; the server side of this is on Lombok and MapStruct, and the persistence side on Spring Data JPA and Hibernate.
Security of the RPC channel
GWT-RPC exposes your Java methods to the network. The checklist below is the minimum; the section’s security pages (SafeHtml and XSS and XSRF, RPC and server security) treat the topics in depth.
-
Authorise every method on the server. The browser can call any method of the service interface with any argument, whether or not a button exists for it. The Bookshelf’s
saveBookchecksgetThreadLocalRequest().getUserPrincipal()and throws the declaredAccessDeniedException; in the lab, without a principal, the call ended with//EX[1,["com.example.bookshelf.shared.AccessDeniedException/35684112"],0,7]. With Spring Security or the container’s security constraints, authorise by role per method (see Spring Security). Never accept an "isAdmin" flag or a user id from a parameter as proof. -
Validate every argument again. The page size cap of
getBooksand the title check ofsaveBookare examples. Bean Validation constraints on the shared DTO are the natural place (see Validation). -
Protect state-changing calls against XSRF with the token mechanism below.
-
Use HTTPS and set the session cookie attributes your container supports (
Secure,HttpOnly,SameSite). RPC carries session cookies like any other request. -
Never put credentials in method parameters. The Developer Guide’s example
emptyMyInbox(String username, String password)is an illustration of the call syntax and a bad design; authenticate through the container or the framework and identify the user from the session. -
Keep secrets and personal data out of DTOs, because every field of a serialised class leaves the server. The
transientkeyword and a dedicated DTO are both guards. -
Treat the policy file as part of the build artefact, deploy it with the module it belongs to, and do not enable
gwt.codeserver.portorgwt.enhancedClasses.enabledin production. -
Do not trust the response either if it can come from another origin: a client that follows a cross-origin entry point evaluates (version 7) or parses (version 8) what that server sends.
-
Set a CSP that fits GWT’s payload (see the section on the wire and Linkers and bootstrap).
GWT-RPC has some built-in defences that are easy to over-rate.
The response begins with //OK or //EX, so a <script src="..."> include of the URL sees a comment and a cross-site script cannot read the data this way (CVE-2007-2378, "JavaScript hijacking", concerns JSON that is exchanged without any protection scheme).
The X-GWT-Permutation header is a custom header, so a plain cross-site <form> cannot set it.
The servlet answers only POST.
Content-Disposition: attachment is set on every response.
None of these identifies the caller, and a request replayed by curl passes all of them.
XSRF protection
The token mechanism of GWT 2.3 and later binds each protected call to the user’s session.
A service interface extends XsrfProtectedService instead of RemoteService (or a method carries @XsrfProtect; @NoXsrfProtect exempts one), the implementation extends XsrfProtectedServiceServlet (the jakarta package in the Bookshelf), and a second servlet, XsrfTokenServiceServlet, hands out tokens.
The default implementation derives the token from the session cookie (the Developer Guide describes an MD5 hash of the cookie value, stateless, which works because an attacker cannot read the cookie), and the gwt.xsrf.session_cookie_name context parameter names that cookie for the token servlet and for every protected servlet.
A session must exist before the first token request, which the scratch application ensured with a small filter that calls getSession(true).
Method-level annotations override interface-level ones.
<context-param>
<param-name>gwt.xsrf.session_cookie_name</param-name>
<param-value>JSESSIONID</param-value>
</context-param>
<servlet>
<servlet-name>xsrfServlet</servlet-name>
<servlet-class>com.google.gwt.user.server.rpc.jakarta.XsrfTokenServiceServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>xsrfServlet</servlet-name>
<url-pattern>/bookshelf/xsrf</url-pattern>
</servlet-mapping>
The client fetches a token first and attaches it to the protected proxy through HasRpcToken:
XsrfTokenServiceAsync xsrf = GWT.create(XsrfTokenService.class);
((ServiceDefTarget) xsrf).setServiceEntryPoint(GWT.getModuleBaseURL() + "xsrf");
xsrf.getNewXsrfToken(new AsyncCallback<XsrfToken>() {
@Override
public void onSuccess(XsrfToken token) {
((HasRpcToken) catalog).setRpcToken(token); // every later call carries it
onReady.run();
}
@Override
public void onFailure(Throwable caught) {
GWT.log("Cannot get an XSRF token", caught);
}
});
In the scratch Bookshelf (the same Jetty setup as the lab, checked when the project was built for this section) the token request and the protected call answered 200, and a call without a token answered with the exception of the protocol:
//EX[2,1,["com.google.gwt.user.client.rpc.RpcTokenException/2345075298","Invalid RPC token (XSRF token missing)"],0,7]
That response reaches the browser only when the JVM can serialise exceptions (see Java 17 and later: exceptions that cannot be serialised); otherwise it is the generic 500.
AbstractXsrfProtectedServiceServlet validates the token inside onAfterRequestDeserialized, so an override of that hook in a protected servlet must call super.onAfterRequestDeserialized(rpcRequest) or the check disappears.
The full treatment, with RpcTokenExceptionHandler and token renewal, is on the XSRF, RPC and server security page.
A servlet is shared between requests
One servlet instance serves all requests concurrently.
getThreadLocalRequest() and getThreadLocalResponse() are thread-locals and are valid only on the request thread.
Keep per-request state in local variables, make shared fields thread-safe (the lab’s in-memory repository uses a ConcurrentSkipListMap and an AtomicInteger), and do not hand the request object to another thread.
The Bookshelf worked example, end to end
The pieces of this page form one runnable slice of the Bookshelf: the shared types and the two service interfaces, the servlet and its mapping, a facade, a poller, a request builder, a presenter with JUnit tests, and a lab entry point that exercises 16 scenarios and prints what happened.
| File | Content |
|---|---|
|
DTOs, an enum, an immutable value type and a record |
|
Custom field serializers for the two types without a default constructor |
|
Declared checked exceptions that travel on Java 21 without a JVM option |
|
The service contract |
|
The servlet (jakarta) and an in-memory catalogue of 25 books |
|
The servlet mapping |
|
The type-checked server twin (server module, shared package) |
|
A delegate servlet around a plain class |
|
Facade, request builder, poller |
|
A presenter and four JVM tests |
|
The lab entry point (not part of a real application) |
The commands that were run (JDK 21, Maven 3.9 offline after the first resolution, Chrome 155 headless):
# build shared, compile the client with GWT, run the JUnit tests and start Jetty 12 on port 8932
# (the lab's server pom binds the Jetty run goal to pre-integration-test to do it in one invocation)
mvn -B -o -pl bookshelf-server -am verify -DskipTests -Djetty.http.port=8932
# JVM tests of the presenter
mvn -B -o verify -pl bookshelf-client -am
# open the page in headless Chrome through the DevTools protocol and print the log it wrote
node lab.js http://localhost:8932/ 30000
The compile of the client module (one permutation, user.agent pinned to safari for speed) printed Compilation succeeded — 1,696s.
| # | Call | Result |
|---|---|---|
1 |
|
|
2 |
|
The server saw |
3 |
|
Two requests in sequence, |
4 |
|
|
5 |
|
|
6 |
|
Built, not sent, then sent; |
7 |
|
|
8 |
poller and |
|
9 |
|
|
10 |
proxy without an entry point |
|
11 |
wrong path; unreachable origin |
|
12 |
|
Two records through the custom serializer |
13 |
|
|
14 |
|
|
15 |
|
|
16 |
a gateway that answers 200 with HTML |
|
The variants that changed the result:
| Variant | Result |
|---|---|
Exceptions without a serializer, no JVM option |
Scenarios 4 and 6 fail with |
The same with |
Both exceptions arrive as themselves; |
Custom serializers for the two exceptions, no JVM option |
Both arrive as themselves, with a smaller payload |
Policy files removed before the first call |
Every call with a custom DTO fails; see Serialisation policy files |
|
Policy downloaded from the code server; the lab passes |
|
Compile warning; service refused; opt-in property restores it (see JPA, JDO and DTOs: the 2.10.1 and 2.11 change) |
|
Every call fails with |
|
The lab passes unchanged |
|
The lab passes; flags |
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
Anything that escapes the pipeline: a runtime exception thrown by the method (logged as |
Read the log: the first line after the warning names the cause. Declare and handle the exceptions that are part of the API. |
|
Java 17 and later deny the reflection that GWT-RPC uses to serialise |
Give each exception a custom field serializer, and start the container with |
|
No |
Annotate the service interface, or set the entry point before the first call |
|
The URL does not reach the servlet: the pattern in |
Compare |
A |
Network error, a blocked cross-origin call (no CORS), or an aborted request |
Check the entry point origin and the CORS configuration, and the browser console |
|
A gateway, proxy or SSO filter answered 200 with a page instead of the servlet |
Detect the HTML (this exception), send the user to the sign-in, and exclude the RPC path from the redirect rules |
|
The policy file is not where the servlet looks: not deployed, another web application, another path, or the client was recompiled and the server serves the old module directory |
Deploy the module directory with its |
|
The method returned an instance of a class that the compiler never saw (a server-only subclass, an entity, a proxy) |
Return an instance of the shared class; map entities to DTOs |
|
A class without a no-argument constructor, a record, a class that is not |
Add the constructor, a custom field serializer, the marker interface or a concrete type |
|
The serializer has only the instance methods |
Add the |
|
The Async interface is missing or lacks a method |
Copy the interface that the compiler prints |
|
A shared class uses a type outside the JRE emulation |
Keep the class out of the GWT source path, or use a serialisable stand-in |
|
A final field is never sent |
Make it non-final or |
|
A client built before the service changed (method or parameters) is still open |
Reload the page; in the callback, offer the reload |
|
The server’s |
Deploy client and server together; never reuse an old browser page with a new server |
|
The request does not come from a GWT client of this generation |
Not a GWT client: do not call the service by hand |
|
A request whose value types do not match the method signature, which a real GWT client never sends |
Treat it as an attack and look at the source of the request |
|
A request without |
Call |
|
A caller that did not use the GWT content type |
Same as above |
|
Protocol version 7 is evaluated with |
Start the server with |
|
A class is an enhanced class ( |
Stop sending entities; use DTOs (see JPA, JDO and DTOs: the 2.10.1 and 2.11 change) |
|
A timeout set by a request builder expired; the server may still run the method |
Make write calls idempotent or tell the user that the result is unknown |
Everything after the call runs before the callback |
Calls are asynchronous |
Move the follow-up code into the callback |
The Developer Guide sample |
A raw |
Write |
|
The shared module’s |
Run |
Where older material is outdated
The Developer Guide and the tutorial still teach the right architecture, with details from the Ant era. Several statements have been overtaken:
-
Direct-Eval RPC. The Server Communication page still has a section on it, while the 2.10.0 release notes say deRPC was removed. The page itself calls the feature a failed experiment and discourages its use.
-
The sample callback. The raw
AsyncCallbackof "Actually making a call" does not compile (see the pitfalls). ItsemptyMyInbox(username, password)also sends credentials as arguments, which is a design to avoid. -
Printing
caught.getMessage(). The Developer Guide’s asynchronous example shows the message of the exception to the user. For aStatusCodeExceptionthat is the status, the status text and the whole response body. -
Serializing enhanced classes. The section describes the GWT 2.0 support for JPA and JDO classes. Since 2.10.1 and 2.11.0 the feature is off by default and the servlet refuses the service (see JPA, JDO and DTOs: the 2.10.1 and 2.11 change).
-
gwt-servlet.jarinwar/WEB-INF/lib,webAppCreator,ant warand the embedded Jetty of development mode. Today the server classes come fromorg.gwtproject:gwt-servletorgwt-servlet-jakartawith Maven; the 2.13.0 release notes say the development mode server now serves only static files, so you run your own container;webAppCreatoris deprecated since 2.11. -
Two connections per browser. The advice about parallel calls assumes an old limit.
-
Exceptions in the tutorials.
DelistedExceptionand any similar declared exception need the measures of Java 17 and later: exceptions that cannot be serialised on a current JDK.
The 2007 book "GWT in Action" (Hanson and Tacy, Manning, first edition, GWT 1.3 and 1.4) devotes two chapters to the same ground: the asynchronous model and the service types, then the client architecture around them (a facade, callback chaining, polling, custom field serializers). The concepts all still hold, and this page’s facade, chaining, poller and serializers are their modern form. The details differ:
-
It requires the
IsSerializablemarker;java.io.Serializablehas been accepted since GWT 1.4. -
It declares collection element types with a Javadoc annotation (
@gwt.typeArgs); generics replaced it in GWT 1.5, and the compiler now warns about raw collections. -
It sets the URL with
ServiceDefTarget.setServiceEntryPointandGWT.getModuleBaseURL();@RemoteServiceRelativePathreplaced that, although the setter still exists. -
Its examples use Java 1.4 syntax, the
javax.servletAPI, hosted mode and servlets that block to emulate server push. -
The security topics of this page (policy files, XSRF tokens, CSP, deserialisation of enhanced classes, Java 17) did not exist or were not covered.
What changed recently
-
2.10.0 (2022-06-09): the
rpc.XserializeFinalFieldsproperty turns on experimental final-field serialisation;LinkedHashSetmay be serialised without a policy; deRPC is removed. -
2.10.1 and 2.11.0 (both 2024-01-09): JPA/JDO enhanced classes are not serialisable by default, with a compile warning; 2.11.0 adds the Jakarta artifacts
gwt-servlet-jakarta(andrequestfactory-server-jakarta) and makes the custom field serializers of GWT ready for Java 17. -
2.12.0 (2024-10-29): fixes for Content Security Policy issues in linkers, development mode and, according to the notes, workarounds for GWT-RPC payloads (see the wire section).
-
2.13.0 (2026-02-11) and 2.13.1 (2026-06-19): the release notes list no RPC-specific change. The development mode server serves only static files (2.13.0), which matters when a tutorial assumes the embedded Jetty.
-
Open: gwtproject/gwt issue 9793 (exceptions on Java 17 and later) was still open on 2026-10-09.
Source: GWT — Release notes