A streaming LLM chat demo that shows how to do token-by-token streaming properly. The backend pushes the model's reply to the browser over Server-Sent Events (SSE) as the tokens arrive, and it does so with the resilience you actually need in production: comment heartbeats to keep the connection alive and surface dead clients, Last-Event-ID resume so a dropped connection replays from where it left off instead of starting over, client-side cancellation that propagates all the way to the OpenAI call, and partial persistence so a half-finished answer is still saved.
The stack is Spring Boot 4.1 (Java 25) with Spring AI talking to OpenAI on the backend, and Next.js 16 with React 19 on the frontend. It accompanies a tutorial, so the code is commented heavily and the moving parts are kept deliberately small.
- JDK 25 (the Gradle wrapper builds against a Java 25 toolchain).
- Node.js 20 or newer for the Next.js frontend.
- An OpenAI API key. The demo defaults to the
gpt-5-minimodel.
There is no Docker here. You run the backend, then run the frontend. Start the backend first: the browser only ever talks to the Next.js proxy routes, which forward to the backend.
Set your OpenAI key as an environment variable, then start the app with the Gradle wrapper.
On macOS or Linux:
cd backend
export OPENAI_API_KEY=sk-...
./gradlew bootRunOn Windows PowerShell:
cd backend
$env:OPENAI_API_KEY="sk-..."
.\gradlew bootRunThe backend serves on http://localhost:8080. It uses an in-memory H2 database with ddl-auto: create-drop, seeded from import.sql on every startup, so the data resets each time you restart. The model is set in backend/src/main/resources/application.yaml if you want to change it.
In a second terminal:
cd frontend
npm install
npm run devThen open http://localhost:3000. If your backend is not on port 8080, point the frontend at it with the BACKEND_URL environment variable (it defaults to http://localhost:8080).
- Send a message and watch the reply stream in token by token rather than appearing all at once.
- Click Stop mid-stream. The cancellation propagates through the proxy to the backend, which aborts the OpenAI call and saves the partial answer it had received so far. Reload the conversation and you will see the truncated reply preserved.
- Browse the seeded conversations in the sidebar. These come from
import.sqland reappear on every backend restart. - Use the suggested prompts on the empty state to get going quickly.
The browser never talks to Spring directly. It calls the Next.js proxy routes under frontend/app/api/, which forward to the backend. This hides the backend origin, sidesteps CORS, and gives auth a natural home in a real deployment.
- The streaming endpoint is
POST /api/chat/streaminbackend/.../controller/ChatStreamController.java. It returns aFlux<ServerSentEvent<String>>, which Spring MVC adapts onto its async machinery and writes astext/event-stream. The streaming, heartbeat and resume logic lives inbackend/.../service/ChatService.java. - The conversation list and history come from
GET /api/conversationsandGET /api/conversations/{id}/messagesinbackend/.../controller/ConversationController.java, served from H2. - The frontend proxy is
frontend/app/api/chat/route.ts(streaming, withrequest.signalforwarded so a Stop cancels the upstream call) plusfrontend/app/api/conversations/route.tsandfrontend/app/api/conversations/[id]/messages/route.ts. - The client-side stream handling, including
Last-Event-IDresume on a dropped connection, is infrontend/app/hooks/useChat.ts.
Resilience behaviours worth noting: each SSE event carries an id of the form messageId:charOffset, so on reconnect the client sends the last id it saw and the server replays from that offset rather than regenerating. Heartbeats are emitted as SSE comments on a fixed interval to keep writes flowing, since the servlet API only notices a dead client on write. Both knobs live under app.sse.* in application.yaml (heartbeat-interval, default 15s; timeout, default 5m).
The backend registers a gauge for the number of currently open SSE streams. With the app running, fetch it from the metrics endpoint:
http://localhost:8080/actuator/metrics/sse.connections.active
Open a chat, start a long reply, and watch the count rise and fall. The H2 web console is enabled in application.yaml and available at http://localhost:8080/h2-console (JDBC URL jdbc:h2:mem:streamingllm, user sa, blank password) if you want to inspect the seeded tables directly.
This is not required to run the demo. If you put a reverse proxy in front of the backend, a buffering proxy will collect the whole response before forwarding it, which destroys the live token-by-token effect: the user waits, sees nothing, then gets the entire answer at once. The fix is to turn buffering off for the stream. An Nginx server block in front of the backend would look like this:
server {
listen 80;
location /api/chat/stream {
proxy_pass http://localhost:8080;
# Without these two lines Nginx buffers the whole SSE response
# and the live streaming effect is lost.
proxy_buffering off;
proxy_set_header X-Accel-Buffering no;
proxy_http_version 1.1;
proxy_read_timeout 5m;
}
}The backend already sets X-Accel-Buffering: no and Cache-Control: no-cache on the stream response, which is the application-level half of the same fix.
springbootnextllm/
├── backend/ Spring Boot 4 / Java 25 / Spring AI
│ ├── build.gradle
│ └── src/main/
│ ├── java/com/tucanoo/streamingllmdemo/
│ │ ├── controller/ ChatStreamController, ConversationController
│ │ ├── service/ ChatService (streaming, heartbeats, resume)
│ │ ├── entity/ Conversation, Message, Role
│ │ ├── repository/ JPA repositories
│ │ ├── dto/ request/response shapes
│ │ ├── config/ chat client, SSE properties, CORS
│ │ └── metrics/ SseConnectionMetrics (active-stream gauge)
│ └── resources/
│ ├── application.yaml model, H2, actuator, app.sse.* knobs
│ └── import.sql seeded conversations (reloaded each run)
└── frontend/ Next.js 16 / React 19
└── app/
├── api/ proxy routes to the backend
│ ├── chat/ streaming proxy
│ └── conversations/ list + history proxies
├── components/ ChatApp UI
├── hooks/ useChat (stream + resume client)
└── lib/ backend URL, SSE parsing, types
- Add authentication with Spring Security and pass the identity through the Next.js proxy.
- Swap H2 for Postgres by changing the datasource and the
ddl-autostrategy so data persists. - Deploy it, putting the Nginx config above (or your platform's equivalent) in front so streaming survives the proxy.