Home | Send Feedback | Share on Bluesky |

Real-Time Polling App with Java and JavaScript

Published: 28. February 2018  •  Updated: 22. March 2026  •  java, javascript

This blog post shows how to build a small polling application where users can vote for their favorite operating system and see the result update live in a pie chart.

The frontend is a small JavaScript application bundled with Vite. The backend is a Spring Boot application that stores the counters in MapDB and pushes updates to all connected browsers with server-sent events.

Stack

Client

Library Homepage
Vite https://vite.dev/
ECharts https://echarts.apache.org/en/index.html
Web Crypto API https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID

Server

Library Homepage
Maven https://maven.apache.org/
Spring Boot https://spring.io/projects/spring-boot
sse-eventbus https://github.com/ralscha/sse-eventbus
MapDB https://mapdb.org/

Overview

The visible part of the application is a page with a vote form and an ECharts pie chart. overview png

The browser sends the selected operating system to the server with a JSON POST request. It also opens a long-lived EventSource connection and listens for live updates.

The server persists the counters in a MapDB file and broadcasts the current snapshot to all connected browsers whenever a user votes.

overview

Server

Create the project on https://start.spring.io with the Spring Web MVC dependency. Then add sse-eventbus and MapDB to the Maven build.

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
      <groupId>ch.rasc</groupId>
      <artifactId>sse-eventbus</artifactId>
      <version>3.0.0</version>
    </dependency>

    <dependency>
        <groupId>org.mapdb</groupId>
        <artifactId>mapdb</artifactId>
        <version>3.1.0</version>
    </dependency>

  </dependencies>

pom.xml

The application entry point enables the SSE event bus with @EnableSseEventBus.

@SpringBootApplication
@EnableSseEventBus
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

Application.java

Static resources are served from ../client/dist in development and from classpath:/static/ in production. The configuration applies different cache settings for index.html and the fingerprinted static files.

@Configuration
class ResourceConfig implements WebMvcConfigurer {

  private final Environment environment;

  ResourceConfig(Environment environment) {
    this.environment = environment;
  }

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    if (this.environment.acceptsProfiles(Profiles.of("development"))) {
      String userDir = System.getProperty("user.dir");
      registry.addResourceHandler("/**")
          .addResourceLocations(Paths.get(userDir, "../client/dist").toUri().toString())
          .setCachePeriod(0);
    }
    else {
      registry.addResourceHandler("/index.html")
          .addResourceLocations("classpath:/static/")
          .setCacheControl(CacheControl.noCache()).resourceChain(false)
          .addResolver(new EncodedResourceResolver());

      registry.addResourceHandler("/**").addResourceLocations("classpath:/static/")
          .setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic())
          .resourceChain(false).addResolver(new EncodedResourceResolver());
    }
  }

  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/").setViewName("redirect:/index.html");
  }

}

ResourceConfig.java

The main application logic lives in PollController. Its constructor opens the MapDB file and initializes the counters when the database is still empty.

  private static final List<OperatingSystem> OPERATING_SYSTEMS = List.of(
      OperatingSystem.WINDOWS, OperatingSystem.MACOS, OperatingSystem.LINUX,
      OperatingSystem.OTHER);

  private final SseEventBus eventBus;

  private final ObjectMapper objectMapper;

  private final DB db;

  private final ConcurrentMap<String, Long> pollMap;

  PollController(SseEventBus eventBus, ObjectMapper objectMapper) {
    this.eventBus = eventBus;
    this.objectMapper = objectMapper;

    this.db = DBMaker.fileDB("./counter.db").transactionEnable().make();
    this.pollMap = this.db.hashMap("polls", Serializer.STRING, Serializer.LONG)
        .createOrOpen();

    for (OperatingSystem operatingSystem : OPERATING_SYSTEMS) {
      this.pollMap.putIfAbsent(operatingSystem.label(), 0L);
    }
  }

PollController.java

The POST /poll endpoint accepts a JSON payload such as { "operatingSystem": "Linux" }, validates the input, increments the persisted counter, commits the change, and then triggers a broadcast.

  @PostMapping("/poll")
  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  public void poll(@RequestBody VoteRequest voteRequest) {
    String label = voteRequest != null ? voteRequest.operatingSystem() : null;
    OperatingSystem operatingSystem = OperatingSystem.fromLabel(label);
    this.pollMap.merge(operatingSystem.label(), 1L, Long::sum);
    this.db.commit();
    sendPollData(null);
  }

PollController.java

The GET /register/{id} endpoint creates an SseEmitter for the browser and immediately sends the current snapshot to the newly connected client so the chart is populated right away.

  @GetMapping("/register/{id}")
  public SseEmitter register(@PathVariable("id") String id,
      HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-store");
    SseEmitter sseEmitter = this.eventBus.createSseEmitter(id, SseEvent.DEFAULT_EVENT);

    // send the initial data only to this client
    sendPollData(id);

    return sseEmitter;
  }

PollController.java

The broadcast payload is a JSON document with a totalVotes field and a results array. The controller serializes this payload with Jackson 3 to JSON and sends it to all connected clients.

  private void sendPollData(String clientId) {
    var builder = SseEvent.builder().data(currentSnapshotJson());
    if (clientId != null) {
      builder.addClientId(clientId);
    }
    this.eventBus.handleEvent(builder.build());
  }

  private String currentSnapshotJson() {
    PollSnapshot snapshot = new PollSnapshot(
        OPERATING_SYSTEMS.stream().map(operatingSystem -> new PollResult(
            operatingSystem.label(),
            this.pollMap.getOrDefault(operatingSystem.label(), 0L))).toList());

    try {
      return this.objectMapper.writeValueAsString(snapshot);
    }
    catch (JacksonException e) {
      throw new IllegalStateException("Unable to serialize poll snapshot", e);
    }
  }

PollController.java

The Java build only packages the Spring Boot application. The frontend build is intentionally separate now: Vite writes the client files to client/dist, and Maven copies that directory into the Spring Boot static resource directory during prepare-package.

Client

Initialize the client project with npm, create the src directory, and install ECharts.

mkdir src
npm install echarts

The main module initializes the chart, checks localStorage to decide whether the vote form should still be displayed, posts the selected value as JSON, and stores a small browser-local marker after a successful vote.

export function init() {
    const chart = echarts.init(elements.chart);
    chart.setOption(getChartOption());

    if (localStorage.getItem('hasVoted') === 'true') {
        showAlreadyVotedState();
    }
    else {
        showVotingState();
    }

    elements.voteButton.addEventListener('click', async () => {
        const selectedOption = document.querySelector('input[name=os]:checked');
        if (!selectedOption) {
            return;
        }

        try {
            await fetch('/poll', {
            method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ operatingSystem: selectedOption.value })
            });

            localStorage.setItem('hasVoted', 'true');
            showVoteSubmittedState();
        }
        catch (error) {
            console.error('Unable to submit vote', error);
        }
    });

app.js

The client id for the SSE registration uses crypto.randomUUID(), which is built into modern browsers through the Web Crypto API. The incoming server-sent event payload is parsed as JSON and applied directly to the chart.

    const eventSource = new EventSource(`/register/${crypto.randomUUID()}`);
    eventSource.addEventListener('message', ({ data }) => {
        const snapshot = JSON.parse(data);
        chart.setOption({
            title: {
                text: `Total Votes: ${snapshot.totalVotes}`
            },
            series: [{
                data: snapshot.results.map(({ label, votes }) => ({
                    value: votes,
                    name: label
                }))
            }]
        });
    }, false);

    window.addEventListener('beforeunload', () => {
        eventSource.close();
        chart.dispose();
    }, { once: true });

app.js

The localStorage check is enough for a demo, but it is only a convenience feature. A real application should enforce one vote per user on the server.

The page layout and styling are implemented with plain CSS, which keeps the example dependency-free apart from the charting library.

Vite handles bundling. Install the build tooling with:

npm install -D vite

The Vite configuration uses src as the project root and writes the production build to dist.

import { defineConfig } from 'vite';

export default defineConfig({
  root: 'src',
  server: {
    proxy: {
      '/poll': 'http://localhost:8080',
      '/register': 'http://localhost:8080'
    }
  },
  build: {
    outDir: '../dist',
    emptyOutDir: true
  }
});

vite.config.js

The build and watch scripts are enough for this example.

  "scripts": {
    "build": "vite build",
    "watch": "vite build --watch"
  },

package.json

vite build --watch only rebuilds files. It does not start a development server, which is exactly what we want here because Spring Boot serves the generated files. The controller is already annotated with @CrossOrigin, so the same backend can also serve a separately hosted frontend if needed.

Running the application

During development, install the client dependencies and start the Vite watcher from the project root:

task client:install
task client:watch

Then start Spring Boot in a second terminal:

task server:run

If you do not use Task, run npm install && npm run watch in client and ./mvnw spring-boot:run -Dspring-boot.run.profiles=development in server.

Open http://localhost:8080 in the browser.

You can find the complete source code for the project on GitHub: https://github.com/ralscha/blog/tree/master/poll