Play is a stateless, asynchronous web framework for Java and Scala. It compiles routes into code, runs on a small number of threads, and keeps no server-side session, so any instance can serve any request. Those choices make Play services cheap to scale out, and they also make one mistake very expensive: blocking a thread that the framework expected to stay free.
This article covers the Java API on Play 3.0 from first principles: the request lifecycle, routes and controllers, asynchronous results and execution contexts, JSON and body parsing, action composition, calling other services, testing, and a worked example service with sizing. For the same framework seen from the Scala side, with more on its architectural opinions, read Play Framework architecture.
Where Play stands: 3.0, Pekko and Java versions
Play 3.0 replaced Akka and Akka HTTP with Apache Pekko and Pekko HTTP, the community fork of Akka 2.6 and Akka HTTP 10.2. It also changed the build groupId from com.typesafe.play to org.playframework. Play 2.9 keeps Akka and is maintained in parallel with the same features, which gives teams with Akka dependencies a supported path while they migrate. As of this writing the latest release is 3.0.11, from May 2026.
Since 2.9, Play requires Java 11 or later and supports Java 11, 17 and 21, with sbt 1.9 or newer as the build tool. If you are coming from older tutorials, note one rename that breaks copied code: HttpExecutionContext is now ClassLoaderExecutionContext.
// project/plugins.sbt
addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.11")
// build.sbt
lazy val root = (project in file("."))
.enablePlugins(PlayJava)
.settings(
name := "orders-service",
scalaVersion := "2.13.16",
libraryDependencies ++= Seq(guice, javaWs, javaJdbc, "org.postgresql" % "postgresql" % "42.7.4")
)The scalaVersion line is needed even in a pure Java project because sbt and Play's generated code use Scala. Pick the version your Play release's documentation lists; the one shown is illustrative.
The request lifecycle and compiled routes
A request passes through four layers. The server backend parses HTTP; Pekko HTTP is the default and Netty is the alternative. Filters run for every request and are where cross-cutting rules live, such as CSRF checks, security headers, allowed hosts and compression; they are listed in play.filters.enabled in conf/application.conf. The router matches the path against the compiled conf/routes file. Finally, action composition wraps the controller method with per-route behaviour such as authentication.
Because routes are compiled, a typo in a controller name or a parameter type is a build error rather than a 404 in production. Play also generates reverse routes, so code can build URLs with routes.OrderController.get(42L) instead of string concatenation, and renaming a path cannot leave stale links behind.
# conf/routes
GET /orders/:id controllers.OrderController.get(request: Request, id: Long)
POST /orders controllers.OrderController.create(request: Request)
GET /orders controllers.OrderController.list(request: Request, page: Int ?= 1)
Asynchronous results and execution contexts
Play's default thread pool is sized for non-blocking work, roughly in line with the number of CPU cores. A controller that returns CompletionStage<Result> lets one thread start many requests and finish each when its data arrives. A controller that blocks on JDBC, a file or a synchronous HTTP client holds that thread for the whole wait. With a few dozen slow queries in flight, every default thread is parked and the service stops answering, including its health check, while CPU sits near idle.
The fix is to give blocking work its own pool, sized to the resource it waits on, and to return a CompletionStage from the controller. Play's CustomExecutionContext binds a pool defined in configuration.
// app/repositories/DatabaseExecutionContext.java
import org.apache.pekko.actor.ActorSystem;
import play.libs.concurrent.CustomExecutionContext;
import javax.inject.Inject;
public class DatabaseExecutionContext extends CustomExecutionContext {
@Inject
public DatabaseExecutionContext(ActorSystem system) {
super(system, "database.dispatcher");
}
}# conf/application.conf
database.dispatcher {
executor = "thread-pool-executor"
throughput = 1
thread-pool-executor { fixed-pool-size = 10 } # equal to the JDBC connection pool size
}Matching the pool to the connection pool is deliberate. More threads than connections only queue inside the connection pool; fewer threads waste connections. The Java side of composing these stages is explained in CompletableFuture in depth.
Controllers, JSON and body parsing
Since Play 2.7 the idiomatic Java controller takes the request as an explicit Http.Request parameter instead of reading a thread-local context. That makes every dependency visible and every method easy to test.
package controllers;
import com.fasterxml.jackson.databind.JsonNode;
import play.libs.Json;
import play.mvc.*;
import repositories.OrderRepository;
import javax.inject.Inject;
import java.util.concurrent.CompletionStage;
public class OrderController extends Controller {
private final OrderRepository repo;
@Inject
public OrderController(OrderRepository repo) { this.repo = repo; }
public CompletionStage<Result> get(Http.Request request, long id) {
return repo.find(id).thenApply(opt -> opt
.map(order -> ok(Json.toJson(order)))
.orElseGet(() -> notFound(Json.newObject().put("error", "no such order"))));
}
@BodyParser.Of(BodyParser.Json.class)
public CompletionStage<Result> create(Http.Request request) {
JsonNode body = request.body().asJson();
NewOrder cmd = Json.fromJson(body, NewOrder.class);
if (cmd.quantity <= 0) {
return java.util.concurrent.CompletableFuture.completedFuture(
badRequest(Json.newObject().put("error", "quantity must be positive")));
}
return repo.insert(cmd).thenApply(id -> created(Json.newObject().put("id", id)));
}
}The repository wraps each JDBC call in CompletableFuture.supplyAsync(() -> ..., dbContext), so the controller never touches a blocking API. Json uses Jackson under the hood. Body parsers buffer in memory up to play.http.parser.maxMemoryBuffer, which is small by default; raise it per route for large JSON uploads rather than globally, and stream truly large uploads to disk or object storage.
Action composition
Filters apply to every request. Action composition applies to the routes you choose, and it can stop a request before the controller runs. A typical use is authentication that rejects missing tokens and otherwise attaches the caller's identity to the request.
public class ApiKeyAction extends Action.Simple {
public static final TypedKey<String> CLIENT = TypedKey.create("client");
private final ApiKeyStore keys;
@Inject
public ApiKeyAction(ApiKeyStore keys) { this.keys = keys; }
@Override
public CompletionStage<Result> call(Http.Request req) {
Optional<String> client = req.getHeaders().get("X-Api-Key").flatMap(keys::clientFor);
if (client.isEmpty()) {
return CompletableFuture.completedFuture(unauthorized("missing or unknown API key"));
}
return delegate.call(req.addAttr(CLIENT, client.get()));
}
}
// On a controller or method:
@With(ApiKeyAction.class)
public CompletionStage<Result> list(Http.Request request, int page) {
String client = request.attrs().get(ApiKeyAction.CLIENT);
...
}Request attributes, typed by TypedKey, carry data from actions to controllers without thread-locals. Order matters when you stack several @With actions, and the annotation order is the execution order, so read it as a pipeline. Keep ApiKeyStore.clientFor fast and cached; it runs on the default pool for every request.
Calling other services
Play's WSClient is a non-blocking HTTP client that returns CompletionStage<WSResponse>. Inject it and always set a request timeout, because a dependency that hangs should cost you one failed request, not a pile of stuck ones.
public CompletionStage<Integer> stockLevel(String sku) {
return ws.url("http://inventory.internal/stock/" + sku)
.setRequestTimeout(Duration.ofMillis(300))
.get()
.thenApply(r -> r.getStatus() == 200 ? r.asJson().get("level").asInt() : -1)
.exceptionally(t -> -1); // timeout or connection error: degrade, don't fail
}If you prefer the JDK's own client for outbound calls, its asynchronous API composes the same way; see the Java HttpClient. Whichever you use, never call .join() or .get() on the future inside a controller running on the default pool.
Worked example: sizing an orders service
Consider an orders service that must handle 800 requests per second at peak. Each GET /orders/:id runs one indexed PostgreSQL query (median 3 ms, p99 15 ms) and one inventory call (median 20 ms, 300 ms timeout).
Database pool. By Little's law, concurrent queries equal arrival rate times time in system: 800 times 0.003 is 2.4 on average, and about 12 at p99 latency. A connection pool of 10 per instance across 3 instances (30 connections) leaves comfortable headroom, and the database dispatcher gets 10 threads per instance to match.
Remote calls. 800 times 0.02 is 16 inventory calls in flight on average, rising toward 240 if inventory slows to its 300 ms timeout. Because WSClient is non-blocking, that costs memory for pending futures, not threads. Had the inventory call been a blocking client on the default pool, those 240 waiting calls would have exhausted the pool and stalled every endpoint.
Composition. The inventory call needs the order's SKU, so the two calls run in sequence and median latency is roughly their sum, about 23 ms.
public CompletionStage<Result> get(Http.Request request, long id) {
CompletionStage<Optional<Order>> order = repo.find(id); // database pool
return order.thenCompose(opt -> opt
.map(o -> inventory.stockLevel(o.sku) // non-blocking
.thenApply(level -> ok(Json.toJson(o.withStock(level)))))
.orElseGet(() -> CompletableFuture.completedFuture(notFound())));
}When two calls are independent, start both stages before combining them, so latency becomes the slower of the two instead of the sum:
CompletionStage<Optional<Order>> order = repo.find(id);
CompletionStage<Integer> promo = promotions.activeCount(customerId); // independent call
return order.thenCombine(promo, (o, n) -> ok(view(o, n)));
Testing
Play's test helpers build a real application with fake requests, so routing, filters and actions are exercised without a network socket.
public class OrderControllerTest extends WithApplication {
@Override
protected Application provideApplication() {
return new GuiceApplicationBuilder()
.overrides(bind(OrderRepository.class).to(InMemoryOrderRepository.class))
.build();
}
@Test
public void missingOrderReturns404() {
Http.RequestBuilder req = Helpers.fakeRequest("GET", "/orders/999")
.header("X-Api-Key", "test-key");
Result result = Helpers.route(app, req);
assertEquals(404, result.status());
}
}Overriding bindings through Guice keeps tests fast and avoids a database. Add a separate integration suite that runs against a real PostgreSQL container to cover SQL and the dispatcher configuration.
Running in production
Package the service with sbt dist, which produces a zip containing a start script and every jar, or build a container image from the staged output of sbt stage. Production mode refuses to start without an application secret, so supply play.http.secret.key from the environment or a secret store, never from the repository; it signs the session cookie, and rotating it logs every user out.
Behind a load balancer, set play.filters.hosts.allowed to the host names clients actually use, or the allowed-hosts filter will reject requests with a 400. Point the balancer's health check at a cheap route that touches no dependency, and add a separate readiness route that checks the database pool. On shutdown, Play runs Pekko's coordinated shutdown, which stops accepting connections and lets in-flight requests finish, so give the orchestrator a termination grace period longer than your slowest request.
Failure modes
| Failure | Symptom | Fix |
|---|---|---|
| Blocking call on the default pool | Throughput collapses, health checks time out, CPU low | Move to a CustomExecutionContext; thread dumps show default threads in JDBC or socket reads |
| Copied pre-2.9 code | HttpExecutionContext does not compile | Use ClassLoaderExecutionContext |
| Mixed Akka and Pekko dependencies | Class conflicts at start-up | On Play 3, use Pekko versions of every library, or stay on 2.9 |
| Session cookie treated as storage | Cookie size errors, stale data | Keep IDs in the session, data in a store |
| Missing secret in production | Application refuses to start in prod mode | Set play.http.secret.key from the environment |
| No timeout on remote calls | Futures pile up when a dependency hangs | setRequestTimeout on every request, plus a fallback |
Trade-offs
Play gives you compiled routes, explicit composition and a concurrency model that handles many slow remote calls on few threads. In exchange, every blocking library needs a deliberate home, the ecosystem is smaller than Spring's, and much of the community material is in Scala. Spring Boot is the usual alternative for Java teams: it has more integrations and more people who know it, and its blocking servlet model is easier to reason about, especially with virtual threads, which are explained in virtual threads in depth. Choose Play when the service is mostly I/O orchestration, statelessness is a goal, and the team will keep to the execution-context discipline.
What to do next
- Create a project with the
org.playframeworksbt plugin on Java 17 or 21 and getsbt runserving a route. - Write a routes file with typed parameters and use reverse routing in one redirect.
- Add a
CustomExecutionContextsized to your JDBC pool and move every blocking call onto it. - Add one
Action.Simplefor authentication that passes identity through aTypedKey. - Call one downstream service with
WSClient, a timeout and a fallback value. - Write a
WithApplicationtest with overridden bindings, then load test and take a thread dump to confirm default threads never block.