Build Your Own Doodle: Group Polls with Quarkus, REST, and WebSockets
A hands-on guide for Java developers to create real-time scheduling polls with Quarkus, PostgreSQL, and Qute.
Coordinating meetings with a group is painful. People propose different times, emails fly around, and no one knows which slot works best. A doodle-like group poll app solves this by letting participants vote on proposed time slots.
In this tutorial you’ll build such an app in Java with Quarkus. You’ll get:
A REST API for managing polls and votes.
A Qute frontend for participants to vote and see results.
Real-time updates with WebSockets Next.
Persistence Hibernate Panache and PostgreSQL.
This is not just a demo. The same approach can be extended for enterprise use cases like shift planning, event registration, or team surveys.
Prerequisites
Make sure you have:
Java 21
Maven 3.9+
Podman (or Docker) for running PostgreSQL via Quarkus Dev Services
Check your setup:
java -version
mvn -version
podman --versionAnd feel free to go to my Github repository and take a look at the full example.
Bootstrap Project
Create a new Quarkus project with the right extensions with the Quarkus CLI.
quarkus create app com.example:poll-doodle:1.0.0 \
--extension=rest-jackson,hibernate-orm-panache,websockets-next,qute,rest-qute,jdbc-postgresql \
--no-code
cd poll-doodleWhy these extensions?
quarkus-rest-jackson: Build JSON-based REST APIs.quarkus-hibernate-orm-panache: Map entities with JPA and simplify persistence.quarkus-websockets-next: Modern, reactive WebSocket support.quarkus-qute: Qute templating engine for server-side HTML.quarkus-rest-qute: REST integration for Qute Templatingjdbc-postgresql: JDBC driver for PostgreSQL.
Configure Persistence
Use Quarkus Dev Services so you don’t need to manually start PostgreSQL.
src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
quarkus.hibernate-orm.log.sql=trueThis will start a PostgreSQL container automatically in dev mode.
Define Entities
We need three concepts: Poll, TimeSlot, and Vote.
Poll.java
package com.example.poll;
import java.util.ArrayList;
import java.util.List;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
@Entity
public class Poll extends PanacheEntity {
public String title;
@OneToMany(mappedBy = "poll", cascade = CascadeType.ALL, orphanRemoval = true)
public List<TimeSlot> slots = new ArrayList<>();
}A poll has a title and multiple time slots.
TimeSlot.java
package com.example.poll;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
@Entity
public class TimeSlot extends PanacheEntity {
public LocalDateTime startTime;
@ManyToOne
@JsonIgnore
public Poll poll;
@OneToMany(mappedBy = "timeSlot", cascade = CascadeType.ALL, orphanRemoval = true)
public List<Vote> votes = new ArrayList<>();
}
Each time slot belongs to a poll and holds votes.
Vote.java
package com.example.poll;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "poll_vote")
public class Vote extends PanacheEntity {
public String participant;
@ManyToOne
@JsonIgnore
public TimeSlot timeSlot;
}Votes connect participants to a chosen slot.
Build the REST API
Expose endpoints to list polls, create polls, and submit votes.
PollResource.java
package com.example.poll;
import java.util.List;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/polls")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PollResource {
@GET
public List<Poll> allPolls() {
return Poll.listAll();
}
@POST
@Transactional
public Poll createPoll(Poll poll) {
// Set the poll reference for each slot
if (poll.slots != null) {
for (TimeSlot slot : poll.slots) {
slot.poll = poll;
}
}
poll.persist();
return poll;
}
@POST
@Path("/{id}/slots/{slotId}/vote")
@Transactional
public Vote vote(@PathParam("id") Long pollId,
@PathParam("slotId") Long slotId,
Vote vote) {
TimeSlot slot = TimeSlot.findById(slotId);
if (slot == null || !slot.poll.id.equals(pollId)) {
throw new NotFoundException();
}
vote.timeSlot = slot;
vote.persist();
PollSocket.broadcastUpdate(pollId);
return vote;
}
}Real-time Updates with WebSockets Next
Here’s where we switch to Quarkus’ new WebSockets-next API.
PollSocket.java
package com.example.poll;
import java.io.Serializable;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.PathParam;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Inject;
@ApplicationScoped
@WebSocket(path = "/ws/polls/{pollId}")
public class PollSocket {
private static final Set<WebSocketConnection> sessions = ConcurrentHashMap.newKeySet();
@Inject
PollSession pollSession;
@OnOpen
public void onOpen(WebSocketConnection connection, @PathParam("pollId") String pollId) {
pollSession.setPollId(Long.parseLong(pollId));
sessions.add(connection);
}
@OnClose
public void onClose(WebSocketConnection connection) {
sessions.remove(connection);
}
public static void broadcastUpdate(Long pollId) {
sessions.stream()
.forEach(c -> c.sendTextAndAwait("update"));
}
}
@SessionScoped
class PollSession implements Serializable {
private Long pollId;
public Long getPollId() {
return pollId;
}
public void setPollId(Long pollId) {
this.pollId = pollId;
}
}Why this matters
@WebSocket: Defines a WebSocket endpoint at/ws/polls/{pollId}.@OnOpenand@OnClose: Lifecycle hooks.broadcastUpdate: Pushes a message to all subscribers of the given poll.
Add Qute Frontend
Participants need a simple HTML UI.
PollPage.java
package com.example.poll;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/ui/polls")
public class PollPage {
@CheckedTemplate
static class Templates {
public static native TemplateInstance list(java.util.List<Poll> polls);
public static native TemplateInstance detail(Poll poll);
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance list() {
return Templates.list(Poll.listAll());
}
@GET
@Path("/{id}")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance detail(@PathParam("id") Long id) {
Poll poll = Poll.findById(id);
if (poll == null)
throw new NotFoundException();
return Templates.detail(poll);
}
@POST
@Path("/{id}/slots/{slotId}/vote")
@Transactional
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response vote(@PathParam("id") Long pollId,
@PathParam("slotId") Long slotId,
@FormParam("participant") String participant) {
TimeSlot slot = TimeSlot.findById(slotId);
if (slot == null || !slot.poll.id.equals(pollId)) {
throw new NotFoundException();
}
Vote vote = new Vote();
vote.participant = participant;
vote.timeSlot = slot;
vote.persist();
PollSocket.broadcastUpdate(pollId);
return Response.seeOther(java.net.URI.create("/ui/polls/" + pollId)).build();
}
}Templates
src/main/resources/templates/PollPage/list.html
<!DOCTYPE html>
<html>
<head>
<title>All Polls</title>
</head>
<body>
<h1>Available Polls</h1>
<ul>
{#for poll in polls}
<li><a href="/ui/polls/{poll.id}">{poll.title}</a></li>
{/for}
</ul>
</body>
</html>src/main/resources/templates/PollPage/detail.html
<!DOCTYPE html>
<html>
<head>
<title>{poll.title}</title>
</head>
<body>
<h1>{poll.title}</h1>
<h2>Vote</h2>
<ul>
{#for slot in poll.slots}
<li>
{slot.startTime}
<form action="/ui/polls/{poll.id}/slots/{slot.id}/vote" method="post">
<input type="text" name="participant" placeholder="Your name" required>
<button type="submit">Vote</button>
</form>
</li>
{/for}
</ul>
<h2>Results</h2>
<ul id="results">
{#for slot in poll.slots}
<li>
{slot.startTime} — {slot.votes.size} votes
</li>
{/for}
</ul>
<script>
const pollId = {poll.id};
const ws = new WebSocket(`ws://` + location.host + `/ws/polls/` + pollId);
ws.onmessage = () => location.reload();
</script>
</body>
</html>What happens here
Voting is just a form post.
Results section shows the current vote counts.
A WebSocket client listens for
"update"messages and reloads the page.
Run and Verify
Start Quarkus in dev mode:
quarkus devCreate a poll with slots:
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Team Sync","slots":[{"startTime":"2025-09-25T10:00"},{"startTime":"2025-09-25T14:00"}]}' \
http://localhost:8080/pollsResult:
{
"id": 1,
"title": "Team Sync",
"slots": [
{
"id": 1,
"startTime": "2025-09-25T10:00:00",
"poll": null,
"votes": []
},
{
"id": 2,
"startTime": "2025-09-25T14:00:00",
"poll": null,
"votes": []
}
]
}Open http://localhost:8080/ui/polls in two browser tabs.
Submit a vote in one tab.
Watch the results update in the other tab instantly.
Production Notes
Replace
drop-and-createwith migrations (Flyway, Liquibase).Secure your endpoints with
quarkus-oidcfor enterprise SSO.For large-scale use, broadcast JSON payloads instead of
"update".Use TLS for WebSocket connections.
You now have a working group poll doodle app in Quarkus with REST, WebSockets Next, persistence, and Qute UI.



