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.

Advertisement

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

HTTP clientbrowser, serviceServer backendPekko HTTP or NettyFiltersCSRF, security headers, hosts, gzipRoutercompiled from conf/routesAction compositionauth, logging, @WithController methodreturns Result or CompletionStageBlocking poolCustomExecutionContext, JDBCWSClientnon-blocking calls to other servicessupplyAsyncws.urlDefault threads stay free; anything that blocks moves to a dedicated pool.
A Play request from socket to controller. Filters and actions compose explicitly, and the controller hands blocking work to a separate pool while remote calls stay non-blocking.

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)
Advertisement

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

FailureSymptomFix
Blocking call on the default poolThroughput collapses, health checks time out, CPU lowMove to a CustomExecutionContext; thread dumps show default threads in JDBC or socket reads
Copied pre-2.9 codeHttpExecutionContext does not compileUse ClassLoaderExecutionContext
Mixed Akka and Pekko dependenciesClass conflicts at start-upOn Play 3, use Pekko versions of every library, or stay on 2.9
Session cookie treated as storageCookie size errors, stale dataKeep IDs in the session, data in a store
Missing secret in productionApplication refuses to start in prod modeSet play.http.secret.key from the environment
No timeout on remote callsFutures pile up when a dependency hangssetRequestTimeout 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

  1. Create a project with the org.playframework sbt plugin on Java 17 or 21 and get sbt run serving a route.
  2. Write a routes file with typed parameters and use reverse routing in one redirect.
  3. Add a CustomExecutionContext sized to your JDBC pool and move every blocking call onto it.
  4. Add one Action.Simple for authentication that passes identity through a TypedKey.
  5. Call one downstream service with WSClient, a timeout and a fallback value.
  6. Write a WithApplication test with overridden bindings, then load test and take a thread dump to confirm default threads never block.
Key takeaway: Play for Java is a stateless, compiled-route framework that runs on a few threads and expects controllers to return CompletionStage results. On Play 3 it runs on Pekko under the org.playframework groupId and needs Java 11 or later. Keep the default pool non-blocking, give JDBC its own pool sized to the connection pool, compose authentication with actions, put timeouts on every remote call, and test through the router with overridden bindings.