How to Build a Real-Time Polling System with WebSockets, React & Spring Boot

In this tutorial, we’ll build a real-time polling system using Spring Boot for the backend and React for the frontend. This system will include advanced features like rate limiting, dynamic UI/UX components, and WebSocket integration for live updates.
Key Features of Real-Time Polling System
- Real-Time Updates: WebSocket integration ensures live updates for all users.
- Rate Limiting: Prevent abuse with Resilience4j rate limiting.
- Dynamic UI: React-powered progress bars and charts for a seamless user experience.
- Docker Deployment: Easily deploy the app using Docker and Docker Compose.
Step 1: Setting Up the Spring Boot Backend
We’ll start by creating a new Spring Boot project with the following dependencies:
- Spring Web: For REST API endpoints.
- Spring Data JPA: For database operations.
- Spring WebSocket: For real-time communication.
- Resilience4j: For rate limiting.
- H2 Database: For in-memory data storage during development.
Ensure you have Spring Boot set up. If not, generate a new project with Spring WebSocket dependency via Spring Initializr.
Add these dependencies to the pom.xml
file:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
1.2 Configure the Database for a Real-Time Polling System
To set up the H2 in-memory database for development, we configure the application.properties
file:
spring.datasource.url=jdbc:h2:mem:pollingdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
#production
#spring.datasource.url=${SPRING_DATASOURCE_URL}
#spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
#spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
This configures an in-memory H2 database accessible via the H2 console at http://localhost:8080/h2-console
. This is ideal for development because it requires no external database setup.
1.3 Data Model Design
We define two entities: Poll
and Option
. Each poll has a question and multiple voting options.
Poll.java
(Entity):
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Poll {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Question is required")
private String question;
@OneToMany(mappedBy = "poll", cascade = CascadeType.ALL, orphanRemoval = true)
@Size(min = 2, message = "At least two options are required")
@JsonManagedReference
private List<Option> options = new ArrayList<>();
private LocalDateTime createdAt;
}
The Poll
class represents a poll with a question and multiple options. The @Entity
annotation marks this class as a JPA entity, meaning it will be mapped to a database table. The @OneToMany
annotation defines a one-to-many relationship between a Poll
and its Option
entities.
This means that each poll can have multiple options, but each option belongs to only one poll.
Option.java
(Entity):
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Option {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Option text is required")
private String text;
private int votes;
@ManyToOne
@JoinColumn(name = "poll_id")
@JsonBackReference
private Poll poll;
}
The Option
class represents a voting option. The @ManyToOne
annotation defines a many-to-one relationship between Option
and Poll
. This means multiple options can belong to a single poll. The @JoinColumn
annotation specifies the foreign key column (poll_id
) in the Option
table that links it to the Poll
table.
1.4 Create the Repository
We create a repository to interact with the database using Spring Data JPA.
PollRepository.java
public interface PollRepository extends JpaRepository<Poll, Long> {}
The PollRepository
interface extends JpaRepository<Poll, Long>
, leveraging Spring Data JPA to handle database operations. By extending this interface, we inherit methods like save()
, findById()
, and findAll()
without writing any SQL.
For example, when pollRepository.save(poll)
is called, it automatically translates into an INSERT
or UPDATE
SQL statement based on whether the poll already exists.
1.5 Implement the Service Layer
The service layer contains business logic for creating polls and handling votes.
@Service
@RequiredArgsConstructor
public class PollService {
private final PollRepository pollRepository;
public Poll createPoll(Poll poll) {
poll.setCreatedAt(LocalDateTime.now());
poll.getOptions().forEach(option -> option.setPoll(poll));
return pollRepository.save(poll);
}
public Poll vote(Long pollId, Long optionId) {
Poll poll = pollRepository.findById(pollId)
.orElseThrow(() -> new ResourceNotFoundException("Poll not found"));
Option option = poll.getOptions().stream()
.filter(o -> o.getId().equals(optionId))
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("Option not found"));
option.setVotes(option.getVotes() + 1);
return pollRepository.save(poll);
}
public List<Poll> getAllPolls() {
return pollRepository.findAll();
}
}
The PollService
class contains methods for creating polls, voting, and retrieving all polls.
createPoll
: This method sets the creation timestamp for a new poll and saves it to the database.vote
: This method increments the vote count for a specific option. It first retrieves the poll and the selected option, updates the vote count, and saves the changes to the database.getAllPolls
: This method retrieves all polls from the database.
We can define a custom exception:
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
1.6 Implementing Rate Limiting
In addition, we can use Resilience4j to limit votes to 3 requests per minute to prevent users from voting excessively.
RateLimiterConfig.java
@Configuration
public class RateLimiterConfig {
@Bean
public RateLimiterRegistry rateLimiterRegistry() {
return RateLimiterRegistry.of(
RateLimiterConfig.custom()
.limitForPeriod(3) // Allow 3 votes per minute
.limitRefreshPeriod(Duration.ofMinutes(1))
.build()
);
}
}
1.7 REST Controller
In this controller, we create polls, manage votes, and broadcast updates via WebSocket, ensuring a seamless real-time experience.
@RestController
@RequestMapping("/api/polls")
@RequiredArgsConstructor
public class PollController {
private final PollService pollService;
private final SimpMessagingTemplate messagingTemplate;
@PostMapping
public ResponseEntity<Poll> createPoll(@RequestBody Poll poll) {
Poll savedPoll = pollService.createPoll(poll);
messagingTemplate.convertAndSend("/topic/polls", savedPoll);
return ResponseEntity.ok(savedPoll);
}
@PostMapping("/{pollId}/vote/{optionId}")
@RateLimiter(name = "voteRateLimiter", fallbackMethod = "voteRateLimitExceeded")
public ResponseEntity<Poll> vote(
@PathVariable Long pollId,
@PathVariable Long optionId
) {
Poll updatedPoll = pollService.vote(pollId, optionId);
messagingTemplate.convertAndSend("/topic/polls", updatedPoll);
return ResponseEntity.ok(updatedPoll);
}
@GetMapping
public ResponseEntity<List<Poll>> getAllPolls() {
return ResponseEntity.ok(pollService.getAllPolls());
}
public ResponseEntity<String> voteRateLimitExceeded(Long pollId, Long optionId, Throwable t) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many votes. Try again later.");
}
}
SimpMessagingTemplate
:
This class is part of Spring’s WebSocket support and is used to send messages to WebSocket clients. In our case, it broadcasts the updated poll to all clients subscribed to/topic/polls
. For example, when a user votes, the updated poll is sent to all connected clients, ensuring they see the latest results in real time.@RateLimiter
:
This annotation is part of the Resilience4j library and is used to limit the number of requests to the/vote
endpoint. In our system, users are allowed to vote 3 times per minute. If they exceed this limit, thevoteRateLimitExceeded
method is called.- Fallback Method (
voteRateLimitExceeded
):
This method is executed when the rate limit is exceeded. It returns an HTTP 429 (Too Many Requests) error with a message informing the user to try again later.

1.8 WebSocket Configuration
Next, configure the WebSocket to broadcast poll updates to all connected clients.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic"); // Clients subscribe here
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-poll")
.setAllowedOrigins("*");
}
}
enableSimpleBroker("/topic")
: This enables a simple message broker where clients can subscribe to topics. In our case, clients subscribe to/topic/polls
to receive updates.addEndpoint("/ws-poll")
: This defines the WebSocket endpoint that clients connect to.withSockJS()
: This ensures compatibility with browsers that don’t support WebSocket by falling back to other protocols like HTTP.
Step 2: Building the React Frontend
2.1 Create a React Project
We start by creating a React app with TypeScript and installing the necessary dependencies:
npx create-react-app polling-client --template typescript
cd polling-client
npm install @stomp/stompjs react-chartjs-2 axios chart.js
2.2 Define the Poll
Type
Let’s define the Poll
type used in the frontend to handle poll data, which will be necessary for both creating polls and displaying them.
Create a new file src/types.ts
to store the Poll
type definition.
// src/types.ts
export interface Option {
id: number;
text: string;
votes: number;
}
export interface Poll {
id: number;
question: string;
options: Otion[];
createdAt: string;
}
2.3 Create a Poll Form
Now, let’s create a form that allows users to submit new polls. This form will capture the question and options and then send the data to the backend.
Create a new file src/components/CreatePollForm.tsx
:
import React, { useState } from 'react';
import axios from 'axios';
const CreatePollForm: React.FC = () => {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState(['', '']); // Two default options
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newPoll = {
question,
options: options.map(optionText => ({ text: optionText, votes: 0 })),
};
// Send the poll to the backend
axios.post('http://localhost:8080/api/polls', newPoll)
.then(response => {
console.log('Poll created:', response.data);
setQuestion('');
setOptions(['', '']);
})
.catch(error => {
console.error('Error creating poll:', error);
});
};
const handleAddOption = () => {
setOptions([...options, '']);
};
const handleOptionChange = (index: number, value: string) => {
const newOptions = [...options];
newOptions[index] = value;
setOptions(newOptions);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Question</label>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
</div>
<div>
<label>Options</label>
{options.map((option, index) => (
<div key={index}>
<input
type="text"
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
required
/>
</div>
))}
<button type="button" onClick={handleAddOption}>Add Option</button>
</div>
<button type="submit">Create Poll</button>
</form>
);
};
export default CreatePollForm;
The CreatePollForm
component allows users to create new polls. The useState
hook is used to manage the state of the form inputs. When the form is submitted, the handleSubmit
function sends the new poll to the backend using axios.post
. After submission, the form resets to its initial state.

2.4 Display Polls with Progress Bars and Charts
Let’s create a component to display the current poll results using charts. We’ll use the react-chartjs-2
library for this.
Create a new file src/components/PollComponent.tsx
:
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Poll } from '../types';
import { Line } from 'react-chartjs-2';
import 'chart.js/auto';
import { Client } from '@stomp/stompjs';
const PollComponent: React.FC = () => {
const [polls, setPolls] = useState<Poll[]>([]);
const [votedOptions, setVotedOptions] = useState<Set<number>>(new Set());
const [errorMessage, setErrorMessage] = useState<string>('');
useEffect(() => {
// Fetch polls from the backend when the component mounts
axios.get('http://localhost:8080/api/polls')
.then(res => setPolls(res.data));
// Initialize WebSocket client
const client = new Client({
brokerURL: 'ws://192.168.18.16:8080/ws-poll',
debug: (str) => {
console.log('WebSocket debug:', str); // Log WebSocket activity
},
reconnectDelay: 5000, // Reconnect after 5 seconds
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
console.log('WebSocket connected:', frame);
client.subscribe('/topic/polls', (message) => {
const updatedPoll = JSON.parse(message.body);
console.log('Received updated poll:', updatedPoll);
// Update React state with the new poll data
setPolls(prevPolls =>
prevPolls.map(poll =>
poll.id === updatedPoll.id ? updatedPoll : poll
)
);
});
};
client.onStompError = (frame) => {
console.error('WebSocket error:', frame);
};
// Activate WebSocket client
client.activate();
// Cleanup WebSocket connection on component unmount
return () => {
client.deactivate();
};
}, []);
const handleVote = (pollId: number, optionId: number) => {
if (votedOptions.has(optionId)) {
setErrorMessage('You have already voted for this option!');
return;
}
axios.post(`http://localhost:8080/api/polls/${pollId}/vote/${optionId}`)
.then(() => {
setVotedOptions(prev => new Set([...prev, optionId]));
setErrorMessage('');
})
.catch((error) => {
if (error.response && (error.response.status === 429 || error.response.status === 409)) {
setErrorMessage('Too many votes. Please try again later.');
} else {
setErrorMessage('Error voting! Please try again.');
}
console.error('Error voting:', error);
});
};
const getChartData = (poll: Poll) => {
return {
labels: poll.options.map(option => option.text),
datasets: [
{
label: 'Votes',
data: poll.options.map(option => option.votes),
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1,
},
],
};
};
return (
<div className="poll-container">
{errorMessage && (
<div className="error-message">
⚠️ {errorMessage}
</div>
)}
{polls.map(poll => {
const totalVotes = poll.options.reduce((sum, option) => sum + option.votes, 0);
return (
<div key={poll.id} className="poll-card">
<h3 className="poll-question">{poll.question}</h3>
<div className="chart-container">
<Line
data={getChartData(poll)}
options={{
responsive: true,
plugins: {
legend: {
position: 'top',
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}}
/>
</div>
<div className="options-list">
{poll.options.map(option => (
<div key={option.id} className="option-item">
<div className="option-content">
<button
className={`vote-button ${votedOptions.has(option.id) ? 'voted' : ''}`}
onClick={() => handleVote(poll.id, option.id)}
disabled={votedOptions.has(option.id)}
>
{option.text}
{votedOptions.has(option.id) && (
<span className="checkmark">✓</span>
)}
</button>
<div className="results-container">
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${(option.votes / (totalVotes || 1)) * 100}%`,
backgroundColor: votedOptions.has(option.id)
? '#4CAF50'
: '#e0e0e0'
}}
/>
</div>
<span className="vote-count">
{option.votes} votes • {totalVotes === 0 ? 0 : Math.round((option.votes / totalVotes) * 100)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
export default PollComponent;
The PollComponent
fetches all polls on initial load using axios.get
. The useEffect
hook ensures this runs only once when the component mounts. This client.activate()
establishes the WebSocket connection.
When a user votes, axios.post
sends a request to the /vote
endpoint. The votedOptions
state tracks which options a user has already voted for, disabling the button to prevent duplicate votes.
To handle the rate limiting on the frontend, we need to display a message or block the voting option when the user exceeds the allowed rate limit. In the handleVote
function, we can catch the error returned by the backend when the rate limit is exceeded (HTTP status 429) and show a notification to the user.
Additionally, the system dynamically calculates the progress bar’s width based on the vote count, visually representing the results.

2.5 CSS Styling for the Poll UI
Add some basic styles for the poll UI, including the progress bar and chart.
Create a src/styles.css
:
.poll-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.poll-card {
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.vote-button {
background: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
}
.vote-button.voted {
background: #cccccc;
cursor: not-allowed;
}
.progress-bar {
width: 200px;
height: 10px;
background: #eeeeee;
border-radius: 5px;
display: inline-block;
}
.progress-fill {
height: 100%;
background: #4CAF50;
border-radius: 5px;
transition: width 0.3s ease-in-out;
}
The CSS styles define the appearance of the polling system. The progress-bar
and progress-fill
classes are used to create a visual representation of the vote counts.
Step 3: Docker Deployment
Now, let’s create the Docker configuration for both the Spring Boot backend and React frontend. We’ll use Docker Compose to make it easy to deploy the whole system in a containerized environment.
1. Dockerize the Backend
Create the Dockerfile
in the root directory of the Spring Boot project:
# Use an OpenJDK base image
FROM openjdk:17-jdk-alpine
# Set the working directory
WORKDIR /app
# Copy the built JAR file to the container
COPY target/polling-app.jar polling-app.jar
# Expose the port the app will run on
EXPOSE 8080
# Run the Spring Boot application
ENTRYPOINT ["java", "-jar", "polling-app.jar"]
This Dockerfile
uses the openjdk:17-jdk-slim
base image to create a lightweight container for the Spring Boot application. The COPY
command copies the compiled JAR file into the container, and the CMD
command runs the application.
2. Dockerize the Frontend
Create a Dockerfile
for the React application:
# Use Node.js base image to build the React app
FROM node:16 AS build
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the source code
COPY . .
# Build the app
RUN npm run build
# Serve the app using a static server
FROM nginx:alpine
# Copy build files to Nginx's default public directory
COPY --from=build /app/build /usr/share/nginx/html
# Expose the port for the frontend
EXPOSE 80
# Run Nginx to serve the app
CMD ["nginx", "-g", "daemon off;"]
This Dockerfile
uses a multi-stage build to first compile the React application and then serve it using an Nginx web server.
3. Docker Compose
Create a docker-compose.yml
file to run both the backend and frontend:
version: '3.8'
services:
backend:
image: polling-backend
build: ./backend
ports:
- "8080:8080"
frontend:
image: polling-frontend
build: ./frontend
ports:
- "3000:80"
The docker-compose.yml
defines two services: backend
and frontend
. The build
paths point to the respective Dockerfiles. The ports
directive maps the container ports to the host machine, allowing access to the app via http://localhost:3000
.
Here’s an enhanced and production-ready Docker Compose setup with PostgreSQL integration, along with the necessary adjustments for your Spring Boot application:
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: postgres_db
restart: always
environment:
POSTGRES_USER: poll_admin
POSTGRES_PASSWORD: secure_password_123
POSTGRES_DB: polling_app
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U poll_admin -d polling_app"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: springboot_polling
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/polling_app
SPRING_DATASOURCE_USERNAME: poll_admin
SPRING_DATASOURCE_PASSWORD: secure_password_123
SPRING_JPA_HIBERNATE_DDL_AUTO: update
ports:
- "8080:8080"
volumes:
- ./backend/logs:/app/logs
frontend:
build: ./frontend
container_name: react_polling
restart: always
depends_on:
- backend
ports:
- "3000:80"
volumes:
postgres_data:
Conclusion
In this tutorial, we’ve built a real-time polling system using Spring Boot and React. We covered everything from setting up the backend with WebSocket integration to creating a dynamic frontend with real-time updates. We also implemented advanced features like rate limiting and Docker deployment to make the app production-ready.
If you’re interested in a similar topic, check out our article on Building a Chat Room Using Pusher and Laravel Echo.
You can find the full working source code on GitHub.
Share this content:
Leave a Comment