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

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

Real-Time Polling System

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 limitingdynamic 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.");
    }
}
  1. 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.
  2. @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, the voteRateLimitExceeded method is called.
  3. 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.
rate-limiter How to Build a Real-Time Polling System with WebSockets, React & Spring Boot

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.

real-time-polling-system-create-form How to Build a Real-Time Polling System with WebSockets, React & Spring Boot

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.

real-time-polling-app-chart-plotting How to Build a Real-Time Polling System with WebSockets, React & Spring Boot

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

Discover more from nnyw@tech

Subscribe now to keep reading and get access to the full archive.

Continue reading