Advanced

Full-stack Java Development with Play Framework and React

Non-blocking REST APIs and WebSockets with Play and Akka Streams, a React+Redux UI and AWS deployment.

Full-stack development with Play Framework (Java) and React, covering non-blocking REST APIs, real-time WebSockets with Akka Streams, Redux state management, and deployment on AWS EC2 and Firebase.


Table of Contents

  1. Course Overview
  2. Application Architecture
  3. Module 1 — Getting Started with Play Framework
  4. Module 2 — WebSocket and Akka Streams
  5. Module 3 — Frontend React + WebSocket + Redux
  6. Module 4 — Deployment
  7. Architecture Diagrams
  8. Reference Tables
  9. Key Points and Best Practices

1. Course Overview

This course builds a complete full-stack application with three main features:

FeatureMain technologyCommunication type
Real-time chatPlay WebSocket + Akka StreamsBidirectional WebSocket
Collaborative document editorPlay WebSocket + React SimpleMDEBidirectional WebSocket
Streaming log viewerAkka Streams + SSEHTTP Streaming (Server-Sent Events)

Full technology stack

LayerTechnologyVersion
Backend frameworkPlay Framework2.8.22
Backend languageJava11
Build toolSBT (Scala Build Tool)1.11.x
StreamingAkka Streams2.6.20
ORMHibernate + JPA5.6.15
DatabaseH2 (in-memory)2.2.220
Dependency injectionGuice(via Play)
Frontend frameworkReact18+
Frontend buildVitelatest
State managementRedux Toolkitlatest
NavigationReact Router DOMlatest
StylesBootstraplatest
Backend hostingAWS EC2 (Ubuntu)
Frontend hostingFirebase Hosting

2. Application Architecture

Overall full-stack architecture

graph TB
    subgraph "Frontend — Firebase Hosting"
        A[React App<br/>Vite + Bootstrap]
        B[Redux Store]
        C[React Router]
    end

    subgraph "Backend — AWS EC2 / localhost:9000"
        D[Play Framework 2.8.x]
        E[Akka Streams<br/>Akka HTTP Server]
        F[ChatController<br/>WebSocket]
        G[DocumentWebSocketController<br/>WebSocket]
        H[DocumentController<br/>REST API]
        I[LogStreamController<br/>SSE Streaming]
        J[JPAApi<br/>Hibernate]
    end

    subgraph "Database"
        K[(H2 In-Memory DB)]
    end

    subgraph "Security"
        L[Cloudflare Tunnel<br/>HTTPS / WSS]
    end

    A -- "WS ws://host/ws/chat" --> F
    A -- "WS ws://host/ws/documents" --> G
    A -- "HTTP GET/POST /documents" --> H
    A -- "HTTP GET /api/stream/logs" --> I
    D --> E
    F --> E
    G --> E
    H --> J
    J --> K
    L -- "HTTPS / WSS" --> D
    B --> A
    C --> A

Play Framework MVC Flow

sequenceDiagram
    participant Client as Browser / Client
    participant Routes as routes (conf/routes)
    participant Controller as Controller (Java)
    participant JPA as JPAApi / Hibernate
    participant DB as H2 Database

    Client->>Routes: HTTP Request (GET/POST /documents)
    Routes->>Controller: Dispatch to the corresponding method
    Controller->>JPA: withTransaction(em -> em.persist(doc))
    JPA->>DB: SQL INSERT / SELECT
    DB-->>JPA: Result
    JPA-->>Controller: Object or list
    Controller-->>Client: HTTP Response (JSON, 200/201)

WebSocket Flow with Akka Streams

sequenceDiagram
    participant C1 as Client 1 (John)
    participant C2 as Client 2 (Emma)
    participant Play as Play WebSocket<br/>(Akka Streams)
    participant Sink as Sink (read)
    participant Queue as SourceQueue<br/>(queue)

    C1->>Play: WebSocket connection
    Play->>Queue: Create SourceQueue for C1
    C2->>Play: WebSocket connection
    Play->>Queue: Create SourceQueue for C2

    C1->>Sink: JSON { type:"chat", message:"Hello there!" }
    Sink->>Queue: offer(message) → C1's queue
    Sink->>Queue: offer(message) → C2's queue
    Queue-->>C1: Message broadcast
    Queue-->>C2: Message broadcast

React state management with Redux

flowchart LR
    Comp["React Component<br/>(Documents.jsx)"] -- "dispatch(saveDocument(doc))" --> Action
    Action["Action<br/>{ type: 'SAVE_DOCUMENT',<br/>payload: doc }"] --> Reducer
    Reducer["Reducer<br/>documentReducer"] -- "New state" --> Store
    Store["Redux Store<br/>{ documents: [...] }"] -- "useSelector(selectDocument)" --> Comp

3. Module 1 — Getting Started with Play Framework

3.1 Prerequisites and setup

ToolRecommended versionNotes
Java JDK11Play 2.8.x is officially tested with Java 11
SBT1.11.xDownload via MSI on Windows
IDEIntelliJ Ultimate or VS CodePlugins: Scala, Play 2 Routes

Recommended IntelliJ plugins:

  • Scala — Play is built on Scala, and SBT is Scala-based
  • Play 2 Routes — Syntax highlighting and navigation in the routes file
  • Scala (Metals) for VS Code

Startup commands:

# Clone the starter project
git clone <repo-url>
cd colab-flow-backend-play-framework

# Clean and compile (downloads all dependencies)
sbt clean compile

# Start the Play server (localhost:9000)
sbt run

3.2 Play project structure

colab-flow-backend-play-framework/
├── app/
│   ├── controllers/
│   │   ├── HomeController.java
│   │   ├── DocumentController.java          ← REST API (GET/POST /documents)
│   │   ├── ChatController.java              ← WebSocket /ws/chat
│   │   ├── DocumentWebSocketController.java ← WebSocket /ws/documents
│   │   └── LogStreamController.java         ← SSE /api/stream/logs
│   ├── models/
│   │   └── Document.java                    ← JPA Entity
│   ├── filters/
│   │   └── DefaultHttpFilters.java          ← CORS
│   ├── utility/
│   │   └── LogQueue.java                    ← Thread-safe log queue
│   └── views/                               ← Not used (React replaces templates)
├── conf/
│   ├── application.conf                     ← Main configuration
│   ├── prod.conf                            ← Production configuration (EC2)
│   └── routes                               ← Endpoint definitions
├── project/
│   └── plugins.sbt                          ← Play Framework plugin
├── build.sbt                                ← Dependencies and project configuration
└── public/                                  ← Static assets

Note: The views/ folder is auto-created by Play for Scala HTML templates, but is not used in this course as the frontend is entirely managed by React.

3.3 Configuration (application.conf)

# H2 in-memory database
db.default.driver = org.h2.Driver
db.default.url = "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false"
db.default.username = "sa"
db.default.password = ""

# JPA / Hibernate
jpa.default = defaultPersistenceUnit
play.jpa.default = "defaultPersistenceUnit"

# Evolutions — automatic JPA table creation
play.evolutions.enabled = true
play.evolutions.autoApply = true

# CORS filter — allow Vite frontend (dev)
play.filters.enabled += "play.filters.cors.CORSFilter"

play.filters.cors {
  allowedOrigins = ["http://localhost:5173"]
  allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  allowedHttpHeaders = ["Accept", "Origin", "Content-Type", "Authorization"]
  allowCredentials = true
}

Important points:

  • DB_CLOSE_DELAY=-1: prevents H2 from closing the database when the connection closes
  • DATABASE_TO_UPPER=false: prevents H2 from uppercasing column and table names
  • Evolutions configured with autoApply = true automatically create tables from @Entity JPA entities
  • OPTIONS in allowedHttpMethods handles browser preflight requests before actual CORS requests

3.4 JPA Entity — Document

package models;

import javax.persistence.*;
import javax.validation.constraints.NotNull;

@Entity
public class Document {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;

    @NotNull
    public String title;
    public String content;
    public String username;

    // No-argument constructor required by JPA
    public Document() {}

    public Document(Long id, String title, String content, String username) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.username = username;
    }
}
AnnotationRole
@EntityTells JPA this class is a database table
@IdPrimary key
@GeneratedValue(IDENTITY)Auto-increment primary key
@NotNullValidation constraint — title is required

3.5 Non-blocking REST APIs

Why non-blocking APIs?

flowchart LR
    subgraph "Blocking approach"
        R1[Request] --> T1[Allocated thread]
        T1 -- "Waits 40ms" --> DB1[(DB)]
        DB1 --> T1
        T1 --> Resp1[Response]
        note1["500 requests = 500 threads waiting 🚫"]
    end

    subgraph "Non-blocking approach"
        R2[Request] --> T2[Main thread]
        T2 -- "delegates to ForkJoinPool" --> BG[Background Thread]
        T2 -- "released immediately ✅" --> Others[Other requests]
        BG --> DB2[(DB)]
        DB2 --> BG
        BG --> Resp2[Response]
    end

POST API — Create a document

public CompletableFuture<Result> createDocument(Http.Request request) {
    LogQueue.logs.add("Creating a new document");

    return CompletableFuture.supplyAsync(() -> {
        JsonNode body = request.body().asJson();
        Document doc = Json.fromJson(body, Document.class);

        jpaApi.withTransaction(em -> {
            em.persist(doc);
        });

        LogQueue.logs.add("Document created successfully");
        return created(Json.toJson(doc));  // HTTP 201
    });
}

GET API — Retrieve all documents

public CompletableFuture<Result> getAllDocuments() {
    LogQueue.logs.add("Getting all documents");

    return CompletableFuture.supplyAsync(() ->
        jpaApi.withTransaction(em -> {
            List<Document> docs = em.createQuery("FROM Document", Document.class)
                    .getResultList();
            return ok(Json.toJson(docs));  // HTTP 200
        })
    );
}

CompletableFuture.supplyAsync mechanism:

  • Uses Java’s internal ForkJoinPool to delegate work to a background thread
  • The main thread is released immediately to handle other requests
  • JPA operations must be inside withTransaction() for transaction management

3.6 routes file

# Routes — conf/routes
GET     /                       controllers.HomeController.index()
GET     /assets/*file           controllers.Assets.versioned(path="/public", file: Asset)

# REST API Documents
GET     /documents              controllers.DocumentController.getAllDocuments
POST    /documents              controllers.DocumentController.createDocument(request: Request)

# WebSocket Chat
GET     /ws/chat                controllers.ChatController.chatSocket

# WebSocket Documents (collaboration)
GET     /ws/documents           controllers.DocumentWebSocketController.documentSocket

# SSE Streaming
GET     /api/stream/logs        controllers.LogStreamController.streamLogs

Note: Methods receiving a request (JSON body) must have request: Request in the routes declaration. The signature must match the Java controller method.

3.7 Build — build.sbt

name := """play-colab-tool"""
organization := "com.example"
version := "1.0-SNAPSHOT"

scalaVersion := "2.13.16"

lazy val root = (project in file("."))
  .enablePlugins(PlayJava)

libraryDependencies ++= Seq(
  // Streaming and WebSocket
  "com.typesafe.akka"  %% "akka-stream"           % "2.6.20",
  // Akka HTTP server (more stable for WS than Netty)
  "com.typesafe.play"  %% "play-akka-http-server"  % "2.8.22",
  // H2 in-memory database
  "com.h2database"      % "h2"                     % "2.2.220",
  // Hibernate ORM
  "org.hibernate"       % "hibernate-core"          % "5.6.15.Final",
  // JPA API
  "javax.persistence"   % "javax.persistence-api"  % "2.2",
  // JPA integration in Play
  "com.typesafe.play"  %% "play-java-jpa"           % "2.8.22",
  // Guice dependency injection
  "com.typesafe.play"  %% "play-guice"              % "2.8.22",
  // CORS, CSRF, security headers
  "com.typesafe.play"  %% "filters-helpers"         % "2.8.22"
)

4. Module 2 — WebSocket and Akka Streams

4.1 Microphone/Headset analogy

To understand the WebSocket architecture with Akka Streams:

Akka conceptAnalogyRole
SinkMicrophoneReads incoming messages from the client
Source / SourceQueueWithCompleteHeadset/EarphonesSends messages to the client
FlowZoom connectionCombines Sink + Source into a bidirectional stream
MaterializerPhysical wiringInstantiates and executes the stream graph
OverflowStrategy.dropHeadBounded FIFO queueIf buffer is full, drops the oldest message

4.2 Sink, Source, and Flow

flowchart LR
    Client["Client Browser"] -- "incoming messages" --> Sink
    Sink["Sink.foreach(msg → broadcast)"] -- "offer(msg)" --> Queue1["Queue Client 1"]
    Sink -- "offer(msg)" --> Queue2["Queue Client 2"]
    Sink -- "offer(msg)" --> QueueN["Queue Client N"]
    Queue1 -- "stream" --> Source1["clientSource 1"]
    Queue2 -- "stream" --> Source2["clientSource 2"]
    QueueN -- "stream" --> SourceN["clientSource N"]
    Source1 -- "WebSocket push" --> C1["Client 1"]
    Source2 -- "WebSocket push" --> C2["Client 2"]
    SourceN -- "WebSocket push" --> CN["Client N"]

Key parameters of Source.queue:

ParameterValueMeaning
bufferSize10Maximum number of messages in the queue
OverflowStrategy.dropHeaddrop oldestIf queue is full, drops the oldest message

Production use case: For payment apps or critical notifications, dropHead is unsuitable. You need to cache messages and resend them when the client is ready. For a real-time location tracker (data per second), dropHead is acceptable as only the latest position matters.

4.3 ChatController — chat WebSocket

package controllers;

import akka.NotUsed;
import akka.stream.Materializer;
import akka.stream.OverflowStrategy;
import akka.stream.javadsl.*;
import play.libs.F;
import play.mvc.Controller;
import play.mvc.WebSocket;
import akka.Done;
import akka.japi.Pair;

import javax.inject.Inject;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CompletionStage;

public class ChatController extends Controller {

    private final Materializer materializer;
    // CopyOnWriteArrayList = thread-safe for concurrent access
    private final List<SourceQueueWithComplete<String>> chatClients =
            new CopyOnWriteArrayList<>();

    @Inject
    public ChatController(Materializer materializer) {
        this.materializer = materializer;
    }

    public WebSocket chatSocket() {
        return WebSocket.Text.acceptOrResult(request -> {

            // 1. Create a Source with buffer size 10
            Source<String, SourceQueueWithComplete<String>> source =
                    Source.queue(10, OverflowStrategy.dropHead());

            // 2. Materialize the Source → get (queue, source)
            Pair<SourceQueueWithComplete<String>, Source<String, NotUsed>> pair =
                    source.preMaterialize(materializer);

            SourceQueueWithComplete<String> clientQueue = pair.first();
            Source<String, NotUsed> clientSource = pair.second();

            // 3. Add queue to list of connected clients
            chatClients.add(clientQueue);
            // Automatically remove when connection closes
            clientQueue.watchCompletion().thenRun(() -> chatClients.remove(clientQueue));

            // 4. Sink: read incoming messages and broadcast to all
            Sink<String, CompletionStage<Done>> sink = Sink.foreach(rawMsg -> {
                for (SourceQueueWithComplete<String> q : chatClients) {
                    q.offer(rawMsg);  // Send to each connected client
                }
            });

            // 5. Combine Sink + Source into a Flow
            Flow<String, String, NotUsed> flow = Flow.fromSinkAndSource(sink, clientSource);
            return CompletableFuture.completedFuture(F.Either.Right(flow));
        });
    }
}

Chat JSON protocol:

// Chat message
{ "type": "chat", "username": "john@techcorp.com", "message": "Hello Emma!" }

// Typing indicator
{ "type": "typing", "username": "john@techcorp.com" }

4.4 DocumentWebSocketController

package controllers;

import akka.NotUsed;
import akka.stream.Materializer;
import akka.stream.OverflowStrategy;
import akka.stream.javadsl.*;
import play.libs.F;
import play.mvc.Controller;
import play.mvc.WebSocket;
import akka.Done;
import akka.japi.Pair;

import javax.inject.Inject;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CompletionStage;

public class DocumentWebSocketController extends Controller {

    private final Materializer materializer;
    private final List<SourceQueueWithComplete<String>> allClients =
            new CopyOnWriteArrayList<>();

    @Inject
    public DocumentWebSocketController(Materializer materializer) {
        this.materializer = materializer;
    }

    public WebSocket documentSocket() {
        return WebSocket.Text.acceptOrResult(request -> {
            Source<String, SourceQueueWithComplete<String>> source =
                    Source.queue(10, OverflowStrategy.dropHead());

            Pair<SourceQueueWithComplete<String>, Source<String, NotUsed>> pair =
                    source.preMaterialize(materializer);

            SourceQueueWithComplete<String> clientQueue = pair.first();
            Source<String, NotUsed> clientSource = pair.second();

            allClients.add(clientQueue);
            clientQueue.watchCompletion().thenRun(() -> allClients.remove(clientQueue));

            Sink<String, CompletionStage<Done>> sink = Sink.foreach((String rawMsg) -> {
                try {
                    // Broadcast as-is — frontend handles message types
                    for (SourceQueueWithComplete<String> q : allClients) {
                        q.offer(rawMsg);
                    }
                } catch (Exception e) {
                    System.out.println("Invalid message: " + rawMsg);
                }
            });

            Flow<String, String, NotUsed> flow = Flow.fromSinkAndSource(sink, clientSource);
            return CompletableFuture.completedFuture(F.Either.Right(flow));
        });
    }
}

Document JSON protocol:

typeTriggerClient-side action
"doc-init"Creating a new documentShow notification to other users
"doc-update"Content modificationSynchronize the Markdown editor in real time
"typing"Typing in progressShow “X is typing…“

4.5 LogStreamController — SSE Streaming

package controllers;

import akka.stream.javadsl.Source;
import akka.util.ByteString;
import play.mvc.Controller;
import play.mvc.Result;
import utility.LogQueue;

import java.time.Duration;

public class LogStreamController extends Controller {

    public Result streamLogs() {
        Source<ByteString, ?> source =
            Source.tick(
                Duration.ofSeconds(0),   // Start immediately
                Duration.ofSeconds(1),   // Tick every second
                "tick"
            )
            .map(tick -> {
                StringBuilder sb = new StringBuilder();
                while (!LogQueue.logs.isEmpty()) {
                    sb.append("data: ").append(LogQueue.logs.poll()).append("\n\n");
                }
                String output = sb.toString();
                if (output.isEmpty()) {
                    // Send "..." to prevent the browser from closing the connection
                    return ByteString.fromString("data: ...\n\n");
                }
                return ByteString.fromString(output);
            });

        // text/event-stream = SSE format expected by browsers
        return ok().chunked(source).as("text/event-stream");
    }
}

SSE (Server-Sent Events) format:

data: Creating a new document\n\n
data: Document created successfully\n\n
data: Getting all documents\n\n
data: ...\n\n

Important: The double \n\n is the SSE event delimiter. Without it, the browser does not recognize the end of an event. If no data is sent for a few minutes, some browsers close the connection — that’s why we continuously send ....


5. Module 3 — Frontend React + WebSocket + Redux

5.1 Creating the project with Vite

# Create the React project with Vite
npm create vite@latest colab-flow-ui -- --template react

# Navigate to the folder
cd colab-flow-ui

# Install base dependencies
npm install

# Install additional dependencies
npm install react-router-dom bootstrap
npm install react-redux @reduxjs/toolkit
npm install react-simplemde-editor easymde

# Start the development server (hot reload)
npm run dev
# → http://localhost:5173

UI project structure:

UI/
├── index.html
├── vite.config.js
├── firebase.json              ← Firebase Hosting config
├── package.json
└── src/
    ├── main.jsx               ← React entry point + Redux Provider
    ├── App.jsx                ← Root component + React Router
    ├── config.js              ← Backend URLs (dev/prod)
    ├── users.js               ← Demo users
    ├── components/
    │   ├── Login.jsx          ← Login form
    │   ├── Dashboard.jsx      ← Main layout with Outlet
    │   ├── Navbar.jsx         ← Bootstrap navigation
    │   ├── Chat.jsx           ← Real-time chat (WebSocket)
    │   ├── Documents.jsx      ← Collaborative editor (WebSocket + Redux)
    │   └── DocumentList.jsx   ← Saved documents list
    └── store/
        ├── store.js           ← Redux store configuration
        └── document/
            ├── documentActions.js   ← Redux actions
            ├── documentReducer.js   ← Pure reducer
            └── documentSelectors.js ← State selectors

5.2 Endpoint configuration

// src/config.js
export const endpoints = {
    dev: {
        apiBase: 'http://localhost:9000',
        wsBase:  'ws://localhost:9000',
    },
    prod: {
        apiBase: 'https://your-cloudflare-tunnel.trycloudflare.com',
        wsBase:  'wss://your-cloudflare-tunnel.trycloudflare.com',
    },
};

// Switch between dev and prod here
export const API_BASE = endpoints.dev.apiBase;
export const WS_BASE  = endpoints.dev.wsBase;

Production security note: Replace hardcoded URLs with environment variables (import.meta.env.VITE_API_BASE) in a real context.

5.3 Routing and main components

// src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import Chat from './components/Chat';
import Documents from './components/Documents';

function App() {
    return (
        <BrowserRouter>
            <Routes>
                <Route path="/" element={<Login />} />
                <Route path="/dashboard" element={<Dashboard />}>
                    {/* Default route: Chat */}
                    <Route index element={<Chat />} />
                    <Route path="chat" element={<Chat />} />
                    <Route path="document" element={<Documents />} />
                </Route>
            </Routes>
        </BrowserRouter>
    );
}
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store/store';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';

ReactDOM.createRoot(document.getElementById('root')).render(
    <Provider store={store}>
        <App />
    </Provider>
);

5.4 Login component

Demo users:

// src/users.js
const users = [
    {
        name: 'John Doe',
        username: 'john@techcorp.com',
        password: 'john@123',
    },
    {
        name: 'Emma Jones',
        username: 'emma@techcorp.com',
        password: 'emma@123',
    }
];
export default users;
// src/components/Login.jsx (main logic)
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import users from '../users';

const Login = () => {
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const navigate = useNavigate();

    const onLogin = (e) => {
        e.preventDefault();  // Prevent page reload
        const user = users.find(
            u => u.username === username && u.password === password
        );
        if (user) {
            localStorage.setItem('username', username);
            navigate('/dashboard');
        } else {
            alert("Invalid credentials");
        }
    };

    return (
        <form onSubmit={onLogin}>
            <input value={username} onChange={e => setUsername(e.target.value)} />
            <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
            <button type="submit">Login</button>
        </form>
    );
};

Production warning: This implementation uses in-memory login to simulate a multi-user environment. In production, secure authentication is required (JWT, OAuth2, server-side sessions).

5.5 Chat component with WebSocket

// src/components/Chat.jsx
import { useEffect, useRef, useState } from "react";
import { WS_BASE } from "../config";

const Chat = () => {
    const ws = useRef(null);  // useRef avoids reconnections on every re-render
    const [chatText, setChatText]       = useState("");
    const [typingStatus, setTypingStatus] = useState("");
    const [messages, setMessages]       = useState([]);
    const [username] = useState(localStorage.getItem('username'));

    useEffect(() => {
        // Connect WebSocket on component mount
        ws.current = new WebSocket(`${WS_BASE}/ws/chat`);
        ws.current.onopen = () => console.log("WebSocket connected.");

        ws.current.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                if (data.type === "chat") {
                    // Display all messages (including own)
                    setMessages(prev => [...prev, `${data.username}: ${data.message}`]);
                } else if (data.type === "typing" && data.username !== username) {
                    // Typing indicator only for other users
                    setTypingStatus(`${data.username} is typing...`);
                    setTimeout(() => setTypingStatus(""), 3000);
                }
            } catch (err) {
                console.error("Invalid message", err);
            }
        };

        // Cleanup: close connection on unmount
        return () => ws.current?.close();
    }, [username]);

    // Send a JSON payload via WebSocket
    const send = (payload) => {
        if (ws.current?.readyState === WebSocket.OPEN) {
            ws.current.send(JSON.stringify(payload));
        }
    };

    const handleSendChat = () => {
        send({ type: "chat", username, message: chatText });
        setChatText("");
    };

    const handleTyping = () => {
        send({ type: "typing", username });
    };

    // ... JSX with Bootstrap Cards
};

Why useRef for the WebSocket?

flowchart TB
    subgraph "Without useRef (problem)"
        R1[Re-render] --> NW1[new WebSocket created]
        NW1 --> DC1[Duplicate connections]
        DC1 --> ERR[Multiple active event handlers ❌]
    end

    subgraph "With useRef (solution)"
        R2[Re-render] --> REF[ws.current unchanged]
        REF --> SAME[Same WebSocket connection ✅]
        SAME --> OK[Single event handler]
    end

5.6 Documents component — real-time collaboration

// src/components/Documents.jsx (main logic)
import { useEffect, useRef, useState } from "react";
import { useDispatch } from "react-redux";
import { API_BASE, WS_BASE } from "../config";
import { saveDocument } from "../store/document/documentActions";

const Documents = () => {
    const [username]      = useState(localStorage.getItem('username'));
    const ws              = useRef(null);
    const [title, setTitle]             = useState("");
    const [value, setValue]             = useState("");  // Markdown content
    const [docOwner, setDocOwner]       = useState(username);
    const [isDocOpen, setIsDocOpen]     = useState(false);
    const [docNotification, setDocNotification] = useState(null);
    const [typingStatus, setTypingStatus] = useState("");
    const dispatch = useDispatch();

    useEffect(() => {
        ws.current = new WebSocket(`${WS_BASE}/ws/documents`);

        ws.current.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);

                if (data.type === "doc-init" && data.username !== username) {
                    // Notification for other users
                    setDocNotification({
                        title: data.title,
                        content: data.message,
                        username: data.username,
                    });
                } else if (data.type === "typing" && data.username !== username) {
                    setTypingStatus(`${data.username} is typing...`);
                    setTimeout(() => setTypingStatus(""), 2000);
                } else if (data.type === "doc-update") {
                    // Real-time content synchronization
                    setValue(data.message);
                }
            } catch (err) {
                console.error("Invalid message", err);
            }
        };
        return () => ws.current?.close();
    }, []);

    const send = (payload) => {
        if (ws.current?.readyState === WebSocket.OPEN) {
            ws.current.send(JSON.stringify(payload));
        }
    };

    // Send the initial document to all clients
    const handleSendDoc = () => {
        if (!title.trim()) return alert("Please enter a title.");
        send({ type: "doc-init", username, title: title.trim(), message: value });
        setDocOwner(username);
        setIsDocOpen(true);
    };

    // Broadcast content updates
    const handleUpdateDoc = () => {
        send({ type: "doc-update", username, message: value });
    };

    // Save via REST API — only the owner
    const handleSaveDoc = async () => {
        if (docOwner && docOwner !== username) return; // Only owner saves

        try {
            const res = await fetch(`${API_BASE}/documents`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ title: title.trim(), content: value, username }),
            });
            if (!res.ok) return alert("Failed to save document.");
            const saved = await res.json();

            // Dispatch to Redux store
            dispatch(saveDocument({
                ...saved,
                createdAt: new Date().toLocaleString("en-US")
            }));
        } catch (err) {
            console.error("Save failed", err);
        }
    };
};

5.7 Redux — Global state management

Actions

// src/store/document/documentActions.js

export const saveDocument = (document) => ({
    type: "SAVE_DOCUMENT",
    payload: document
});

export const setDocuments = (documents) => ({
    type: "SET_DOCUMENTS",
    payload: documents
});

Reducer

// src/store/document/documentReducer.js
const initialState = {
    documents: []
};

const documentReducer = (state = initialState, action) => {
    switch (action.type) {
        case "SAVE_DOCUMENT":
            return {
                ...state,
                // Spread operator = clone state (Redux immutability)
                documents: [...state.documents, action.payload],
            };
        case "SET_DOCUMENTS":
            return {
                ...state,
                documents: action.payload
            };
        default:
            return state;
    }
};

export default documentReducer;

Selector

// src/store/document/documentSelectors.js
export const selectDocument = (state) => state.document;

Store

// src/store/store.js
import { configureStore } from "@reduxjs/toolkit";
import documentReducer from "./document/documentReducer";

const store = configureStore({
    reducer: {
        document: documentReducer
    }
});

export default store;

Key Redux principles:

ConceptDefinitionRule
ActionObject { type, payload } describing what should changeDoes not modify the store directly
ReducerPure function (state, action) → newStateNo side effects
StoreCentral database of the appSingle source of truth
SelectorFunction that extracts a part of the storeDeclared in documentSelectors.js
dispatchSends an action to the reducerCalled via useDispatch() hook
ImmutabilityNever mutate state directlyAlways use spread {...state}

6. Module 4 — Deployment

6.1 Deploying the backend on AWS EC2

flowchart LR
    A[Local Machine] -- "sbt clean dist" --> B[target/universal/*.zip]
    B -- "scp via PEM" --> C[AWS EC2 Ubuntu]
    C -- "unzip + run" --> D[Play App :9000]

Backend deployment steps:

# 1. Generate a secret key (KeyGen.java class with SecureRandom)
# 2. Create prod.conf with the secret key
# 3. Generate the distribution package
sbt clean dist
# → creates target/universal/play-colab-tool-1.0-SNAPSHOT.zip

# 4. On EC2 — set PEM file permissions
chmod 400 your-key.pem

# 5. Connect to EC2 instance
ssh -i your-key.pem ubuntu@<ec2-public-ip>

# 6. Transfer and extract the zip
scp -i your-key.pem target/universal/*.zip ubuntu@<ec2-ip>:~/
unzip *.zip

# 7. Launch the Play app in production
./bin/play-colab-tool -Dconfig.file=conf/prod.conf

# 8. Launch in background with nohup
nohup ./bin/play-colab-tool -Dconfig.file=conf/prod.conf &

conf/prod.conf:

include "application.conf"

# Secret key generated by SecureRandom
play.http.secret.key = "${APPLICATION_SECRET}"

# Allow the Cloudflare tunnel domain
play.filters.hosts {
  allowed = [".trycloudflare.com", "localhost:9000"]
}

6.2 Deploying the frontend on Firebase

# 1. Create a Firebase project at console.firebase.google.com
# 2. Install Firebase CLI and log in
npx firebase-tools login

# 3. Initialize Firebase Hosting
npx firebase-tools init hosting
# → Select the created Firebase project
# → Single page app: Yes
# → GitHub auto-builds: No (for this course)

# 4. Edit firebase.json — change "public" → "dist"
# {
#   "hosting": {
#     "public": "dist",     ← here
#     "rewrites": [{ "source": "**", "destination": "/index.html" }]
#   }
# }

# 5. Production build
npm run build
# → creates the dist/ folder

# 6. Delete the automatically generated public/ folder
rm -rf public/

# 7. Deploy
npx firebase-tools deploy
# → Provides the Firebase Hosting URL of the app

6.3 SSL with Cloudflare Tunnel

Firebase requires HTTPS/WSS for connections. A Cloudflare tunnel provides temporary SSL for testing:

# On the EC2 instance
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
dpkg -i cloudflared-linux-amd64.deb

# Open tunnel to the Play app
cloudflared tunnel --url http://localhost:9000
# → Returns a temporary URL: https://xxx.trycloudflare.com
// Update config.js with the Cloudflare domain
export const endpoints = {
    prod: {
        apiBase: 'https://xxx.trycloudflare.com',
        wsBase:  'wss://xxx.trycloudflare.com',
    },
};

Note: For permanent deployment, register a domain with a proper SSL certificate (Let’s Encrypt, AWS Certificate Manager). The Cloudflare tunnel is for development testing only.


7. Architecture Diagrams

Complete deployment architecture

graph TB
    subgraph "Developer"
        D[Local Machine<br/>localhost:9000 + :5173]
    end

    subgraph "Production"
        subgraph "AWS EC2"
            EC2[Ubuntu Instance]
            PLAY[Play App :9000<br/>Java 11 + Akka]
            CF[Cloudflare Tunnel<br/>HTTPS / WSS]
        end

        subgraph "Firebase"
            FB[Firebase Hosting<br/>HTTPS]
            REACT[React Bundle<br/>dist/]
        end
    end

    subgraph "Users"
        U1[Browser John]
        U2[Browser Emma]
    end

    PLAY --- EC2
    CF --- PLAY
    REACT --- FB

    U1 -- "HTTPS" --> FB
    U2 -- "HTTPS" --> FB
    U1 -- "WSS / HTTPS" --> CF
    U2 -- "WSS / HTTPS" --> CF

Chat message lifecycle

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Connected : new WebSocket(url)
    Connected --> Sending : handleSendChat()
    Sending --> Connected : JSON sent via ws.send()
    Connected --> Receiving : ws.onmessage
    Receiving --> Connected : state updated (setMessages)
    Connected --> Disconnected : ws.close() (useEffect cleanup)

Document collaboration flow

sequenceDiagram
    participant John as John (Browser)
    participant Play as Play Backend<br/>(DocumentWebSocketController)
    participant Emma as Emma (Browser)

    John->>Play: WS Connect /ws/documents
    Emma->>Play: WS Connect /ws/documents

    John->>Play: { type:"doc-init", title:"API Design", username:"john" }
    Play->>Emma: Broadcast { type:"doc-init", title:"API Design", username:"john" }
    Note over Emma: Notification: John created a document

    Emma->>Play: handleOpenDocument() — opens editor

    John->>Play: { type:"typing", username:"john" }
    Play->>Emma: Broadcast { type:"typing", username:"john" }
    Note over Emma: "john is typing..."

    John->>Play: { type:"doc-update", message:"# Introduction\n..." }
    Play->>Emma: Broadcast { type:"doc-update", message:"..." }
    Note over Emma: Markdown editor synchronized in real time

    John->>Play: POST /documents (fetch API)
    Play->>Play: JPA persist() in H2
    Play-->>John: HTTP 201 { id:1, title:"API Design", ... }
    Note over John: dispatch(saveDocument(...)) → Redux Store

8. Reference Tables

Play backend REST APIs

MethodEndpointControllerDescription
GET/documentsDocumentController.getAllDocumentsRetrieves all documents (JPA)
POST/documentsDocumentController.createDocumentCreates a document (JSON body)
GET/ws/chatChatController.chatSocketChat WebSocket connection
GET/ws/documentsDocumentWebSocketController.documentSocketDocuments WebSocket connection
GET/api/stream/logsLogStreamController.streamLogsSSE log stream

React hooks used

HookUsage in the courseFile
useStateManage local states (messages, title, etc.)Chat.jsx, Documents.jsx, Login.jsx
useEffectWebSocket connection on mount, cleanup on unmountChat.jsx, Documents.jsx
useRefStore WebSocket instance without triggering re-renderChat.jsx, Documents.jsx
useNavigateRedirect after loginLogin.jsx
useDispatchDispatch Redux actionsDocuments.jsx
useSelectorRead the Redux storeDocumentList.jsx

WebSocket message types

typeDirectionPayloadEffect
"chat"Client → Broadcast{ username, message }Display in chat feed
"typing"Client → Broadcast{ username }Show “X is typing…”
"doc-init"Client → Broadcast{ username, title, message }Notification + initialization
"doc-update"Client → Broadcast{ username, message }Synchronize Markdown editor

Common SBT commands

CommandDescription
sbt clean compileClean and compile (downloads dependencies)
sbt runStart Play server in development mode
sbt testRun tests
sbt clean distGenerate distribution package (.zip)
sbt ~compileAuto-recompile on each change

Firebase commands

CommandDescription
npx firebase-tools loginFirebase authentication
npx firebase-tools init hostingInitialize Firebase Hosting
npm run buildGenerate production bundle (dist/)
npx firebase-tools deployDeploy to Firebase Hosting

9. Key Points and Best Practices

Non-blocking design with CompletableFuture

Always wrap I/O operations (database, network calls) in CompletableFuture.supplyAsync() to free Play’s main thread and maintain high concurrency.

Akka Streams back-pressure management

Back-pressure occurs when a client consumes messages more slowly than the server produces them. Management strategies:

StrategyOverflowStrategyUse case
Drop the oldestdropHeadGPS tracker, non-critical real-time data
Drop the newestdropTailRare
QueuebackpressureCritical apps (payments)
Cache and resendCustom logicTransactional notifications

CORS security in production

# application.conf — DO NOT use "*" in production
play.filters.cors {
  allowedOrigins = ["https://your-firebase-app.web.app"]
  allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  allowedHttpHeaders = ["Accept", "Origin", "Content-Type", "Authorization"]
  allowCredentials = true
}

Redux state immutability

// ❌ INCORRECT — direct state mutation
case "SAVE_DOCUMENT":
    state.documents.push(action.payload);  // Mutation ❌
    return state;

// ✅ CORRECT — immutable new state
case "SAVE_DOCUMENT":
    return {
        ...state,
        documents: [...state.documents, action.payload]  // Clone ✅
    };

WebSocket resource cleanup (useEffect cleanup)

useEffect(() => {
    ws.current = new WebSocket(url);
    // ... handlers

    // Close connection when component unmounts
    return () => ws.current?.close();
}, []);

Without the cleanup function, WebSocket connections remain open after navigating to another page, causing memory leaks and message duplications.

Play deployment — Secret key

// utility/KeyGen.java
import java.security.SecureRandom;
import java.util.Base64;

public class KeyGen {
    public static void main(String[] args) {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        System.out.println(Base64.getEncoder().encodeToString(bytes));
    }
}

In production, the Play secret key must never be hardcoded in the source code. Use an environment variable: play.http.secret.key = ${APPLICATION_SECRET}.

Architecture recommendation — Separation of concerns

Play Backend (lightweight broadcast server)
├── WebSocket Controllers     → Broadcast JSON without parsing (delegate to frontend)
├── REST Controllers          → Non-blocking CRUD with CompletableFuture
└── Streaming Controllers     → SSE with Akka Source.tick

React Frontend (message business logic)
├── useRef(WebSocket)         → Single connection per component
├── useEffect (cleanup)       → Close connection on unmount
├── JSON type field           → Discriminate message types
└── Redux Store               → Global state of saved documents

Search Terms

full-stack · java · development · play · framework · react · backend · architecture · web · websocket · flow · redux · akka · apis · chat · component · deployment · document · management · non-blocking · state · streams · api · collaboration

Interested in this course?

Contact us to book it or get a custom training plan for your team.