跪拜 Guibai
← Back to the summary

Short Polling to WebSocket: Four Ways to Push Real-Time Data to a Dashboard

4 Push Schemes for Real-Time Data Dashboards: Short Polling → Long Polling → SSE → WebSocket

Introduction

Last week a friend was asked in an interview: How did you implement visualization at your last company? If you were to build a real-time data dashboard, how would you do it?

He said: Sounds simple, right? Just use setInterval on the frontend to call the API every second?

Until the interviewer asked: Haven't you used WebSocket?

Today we systematically investigate all real-time data push schemes, from the crudest short polling to full-duplex WebSocket, running through the code and principles of each.

What problem does this article solve? When you need a "backend data changes → frontend real-time display" scenario, which push scheme to choose, and how to write the Spring Boot code for each.

Environment Info: Spring Boot 3.2.7 | JDK 21 | Frontend: vanilla HTML + ECharts (CDN)


Scheme Panorama

The essential difference among the four schemes lies in—who holds the data initiative:

Short Polling:  Client actively pulls ──→ "New data?""No""New data?""No"...
Long Polling:   Client actively pulls ──→ Server holds first, responds when data arrives
SSE:            Server actively pushes ──→ Unidirectional (Server→Client)
WebSocket:      Bidirectional communication ──→ Full-duplex (Client↔Server)
Dimension Short Polling Long Polling SSE WebSocket
Real-time Seconds Sub-second Real-time Real-time
Direction Unidirectional (C→S) Unidirectional (C→S→C) Unidirectional (S→C) Bidirectional
Connection Model Short connection Long connection (on-demand) Long connection (unidirectional) Long connection (bidirectional)
Browser Compatibility 100% 100% All except IE Full support
Server Complexity ⭐⭐ ⭐⭐ ⭐⭐⭐
HTTP/2 Multiplexing ❌ (exclusive connection) N/A (independent protocol)
Native Reconnection Support ✅ (EventSource API) Manual implementation required

Scheme 1: Short Polling — Simplest and Most "Fake Real-Time"

Principle

The frontend periodically initiates HTTP requests, and the backend directly returns the latest data. Essentially a pull model, real-time performance depends on the polling interval.

Client:     ══req══▶  ══req══▶  ══req══▶
Server:     ◀══res══  ◀══res══  ◀══res══
Timeline:   0s         2s         4s

Backend Code

// ShortPollingController.java
@RestController
public class ShortPollingController {

    private final MetricSimulator simulator;

    public ShortPollingController(MetricSimulator simulator) {
        this.simulator = simulator;
    }

    @GetMapping("/api/poll/short")
    public ResponseEntity<Map<String, Object>> shortPoll() {
        return ResponseEntity.ok(Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "orders", simulator.getOrderCount(),
            "timestamp", System.currentTimeMillis()
        ));
    }
}

Frontend Code

// Pull data every 2 seconds
setInterval(async () => {
    const res = await fetch('/api/poll/short');
    const data = await res.json();
    updateChart(data);  // Update ECharts chart
}, 2000);

Core Problems

The biggest problem with this scheme isn't the code you wrote, but the details you didn't write:

  1. Server Pressure: N clients × 0.5 requests per second = linear growth over time. 100 clients means 50 requests per second—not huge, but most requests return data that hasn't changed at all.
  2. Real-time Ceiling: Polling interval = maximum delay. Set a 1-second interval, you may see new data up to 1 second late. Set 100ms, request volume multiplies by 10.
  3. Resource Waste: 99% of requests may pull nothing—the data simply hasn't changed.

Applicable Scenarios


Scheme 2: Long Polling — Server "Queues" for You

Principle

The client initiates a request, but the server doesn't return immediately—it holds first, waiting until new data arrives before responding. After the client receives the response, it immediately initiates the next request. This pattern is also called "Comet".

Client:     ════════req════════▶
Server:     (hold... wait for new data...) ◀═══════res══════
Client:                             ════════req════════▶

Spring Boot Implementation: DeferredResult

// LongPollingController.java
@RestController
public class LongPollingController {

    // Stores all waiting clients
    private final CopyOnWriteArrayList<DeferredResult<Map<String, Object>>> waiters
            = new CopyOnWriteArrayList<>();

    private final MetricSimulator simulator;

    public LongPollingController(MetricSimulator simulator) {
        this.simulator = simulator;
        // Simulate: every 1 second when new data arrives, notify all waiting clients
        Executors.newSingleThreadScheduledExecutor()
            .scheduleAtFixedRate(this::notifyAllWaiters, 0, 1, TimeUnit.SECONDS);
    }

    @GetMapping("/api/poll/long")
    public DeferredResult<Map<String, Object>> longPoll() {
        // 30-second timeout, return empty on timeout (client will reconnect)
        DeferredResult<Map<String, Object>> result =
                new DeferredResult<>(30_000L, Map.of("timeout", true));

        waiters.add(result);

        result.onCompletion(() -> waiters.remove(result));
        result.onTimeout(() -> waiters.remove(result));

        return result;
    }

    private void notifyAllWaiters() {
        Map<String, Object> data = Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "timestamp", System.currentTimeMillis()
        );
        // Notify all waiters and clear the list
        List<DeferredResult<Map<String, Object>>> snapshot;
        snapshot = new ArrayList<>(waiters);
        waiters.clear();
        for (DeferredResult<Map<String, Object>> waiter : snapshot) {
            waiter.setResult(data);
        }
    }
}

Frontend Code

async function longPoll() {
    try {
        const res = await fetch('/api/poll/long');
        const data = await res.json();
        if (!data.timeout) {
            updateChart(data);
        }
    } finally {
        longPoll();  // Initiate next request immediately after getting result
    }
}
longPoll();  // Start

Key Points

Applicable Scenarios


Scheme 3: SSE (Server-Sent Events) — HTTP Native Push

Principle

SSE is part of the HTML5 standard. The client establishes an HTTP long connection via the EventSource API, and the server pushes data unidirectionally in text/event-stream format. The client automatically handles reconnection, no manual retry logic needed.

Client:     ═══req══▶
Server:     ◀══event════◀══event════◀══event════ (continuous push...)

Spring Boot Implementation: SseEmitter

// SseController.java
@RestController
public class SseController {

    private final MetricSimulator simulator;

    public SseController(MetricSimulator simulator) {
        this.simulator = simulator;
    }

    @GetMapping(value = "/api/sse/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream() {
        // Timeout set to 0 means never timeout (actually determined by underlying connection)
        SseEmitter emitter = new SseEmitter(0L);

        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        ScheduledFuture<?> future = executor.scheduleAtFixedRate(() -> {
            try {
                Map<String, Object> data = Map.of(
                    "cpu", simulator.getCpuUsage(),
                    "qps", simulator.getQps(),
                    "timestamp", System.currentTimeMillis()
                );
                emitter.send(SseEmitter.event()
                    .name("metric-update")  // Named event, frontend can listen by event name
                    .data(data));
            } catch (IOException e) {
                // Client disconnected
                emitter.completeWithError(e);
            }
        }, 0, 1, TimeUnit.SECONDS);

        // Clean up scheduled task when connection closes
        emitter.onCompletion(() -> future.cancel(true));
        emitter.onTimeout(() -> future.cancel(true));
        emitter.onError(e -> future.cancel(true));

        return emitter;
    }
}

Frontend Code — EventSource API, Done in Three Lines

const es = new EventSource('/api/sse/stream');

// Listen for named events (corresponds to backend .name("metric-update"))
es.addEventListener('metric-update', (e) => {
    const data = JSON.parse(e.data);
    updateChart(data);
});

// Automatic reconnection on disconnect, no code needed!
// If you want to do something on reconnect:
es.addEventListener('error', (e) => {
    console.log('Connection lost, EventSource is auto-reconnecting...');
});

SSE's Gains and Limits

Advantages:

Limitations:


Scheme 4: WebSocket — Full-Duplex Bidirectional Communication

Principle

WebSocket upgrades an HTTP connection to a TCP long connection through a single HTTP Upgrade handshake, after which both parties can send messages to each other at any time. It has its own frame protocol, with headers only 2-14 bytes, much lighter than HTTP headers.

Client:     ═══Upgrade══▶
Server:     ◀══101 Switching══
After:      ◀══════▶ Full-duplex frame communication ◀══════▶

Spring Boot Implementation: STOMP over WebSocket

STOMP (Simple Text Oriented Messaging Protocol) is a sub-protocol on top of WebSocket, providing "topic subscription" semantics, used like a message queue.

Step 1: Configuration

// WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // Prefix for client subscriptions (server→client)
        registry.enableSimpleBroker("/topic");
        // Prefix for client-sent messages (client→server)
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // SockJS is a WebSocket fallback (auto-degrades to long polling when WebSocket unsupported)
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

Step 2: Server Scheduled Push

// WebSocketPushService.java
@Service
public class WebSocketPushService {

    private final SimpMessagingTemplate messagingTemplate;
    private final MetricSimulator simulator;

    public WebSocketPushService(SimpMessagingTemplate messagingTemplate,
                                 MetricSimulator simulator) {
        this.messagingTemplate = messagingTemplate;
        this.simulator = simulator;
    }

    @Scheduled(fixedRate = 1000)
    public void pushMetrics() {
        Map<String, Object> data = Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "timestamp", System.currentTimeMillis()
        );
        // Broadcast to all clients subscribed to /topic/metrics
        messagingTemplate.convertAndSend("/topic/metrics", data);
    }
}

Step 3: Frontend Code

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2/lib/stomp.min.js"></script>
<script>
const socket = new SockJS('/ws');
const stomp = Stomp.over(socket);

stomp.connect({}, () => {
    stomp.subscribe('/topic/metrics', (msg) => {
        const data = JSON.parse(msg.body);
        updateChart(data);
    });
});
</script>

WebSocket Production Considerations

WebSocket is the most powerful, but also has the most things to consider in production:


Schemes 5 & 6: MQTT and RSocket — Advanced Options

These two schemes aren't demonstrated in the Demo, but as a senior engineer you should know they exist:

MQTT: Top choice for IoT scenarios. Ultra-lightweight (minimum header only 2 bytes), supports QoS (message quality assurance), broker can cache messages after client disconnection. If your "real-time dashboard" targets embedded devices or mobile weak-network environments, MQTT is more suitable than WebSocket. Java implementation can use Eclipse Mosquitto + Spring Integration MQTT.

RSocket: Reactive network protocol, supports four interaction modes (request-response, stream, channel, fire-and-forget). If your microservice system is already WebFlux + Reactor stack, RSocket's requestStream mode naturally fits server push. However, browsers currently don't support RSocket; it needs WebSocket bridging.


Selection Decision Tree

Need bidirectional communication? (Client also needs to send messages to server)
   ├── Yes ──▶ WebSocket
   └── No  ──▶ High real-time requirement?
                 ├── Yes ──▶ Need to support older browsers?
                 │            ├── Yes ──▶ Long Polling
                 │            └── No  ──▶ SSE (Recommended)
                 └── No  ──▶ Short Polling

One-sentence advice:

For internal data dashboards, unidirectional push, use SSE. Minimal code, automatic reconnection, HTTP protocol with no extra deployment cost.

For bidirectional interaction needs (like chat rooms, collaborative editing), use WebSocket.

For uncertain requirements or rapid prototyping, start with short polling to get running first.


Production Environment Watchlist ⚠️

Writing up to here, the Demo code runs fine. But when you prepare to deploy to production, you'll likely encounter the following pitfalls:

Point 1: Nginx Reverse Proxy "Swallows" SSE

The most common crash scene: SSE debugging works fine locally, but after deploying to server, frontend receives no streaming data, or gets a huge chunk all at once after 30 seconds.

Cause: Nginx has proxy_buffering on by default, which buffers backend responses in memory/disk first, sending to client only when accumulated. This is optimization for normal HTTP responses, but disaster for SSE.

Solution: Disable buffering in Nginx location configuration:

location /api/sse/ {
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 86400s;  # SSE is long connection, set timeout large
    proxy_set_header Connection '';
    proxy_http_version 1.1;     # Long connection needs HTTP/1.1
}

Additionally, add X-Accel-Buffering: no to response headers (Spring side response.setHeader("X-Accel-Buffering", "no")), which tells Nginx it shouldn't buffer this response.

Point 2: Spring MVC Async Timeout Is Shorter Than You Think

I passed 0L (never timeout) in the SseEmitter constructor, but Spring MVC has a global config spring.mvc.async.request-timeout, defaulting to only 30 seconds. If you don't change this, SseEmitter will be forcibly timed out after 30 seconds.

# application.yml
spring:
  mvc:
    async:
      request-timeout: -1   # -1 means never timeout

Similarly, the DeferredResult timeout (30s) in long polling should also coordinate with this config.

Point 3: scheduleAtFixedRate May Cause Task Backlog

In SseController, I used scheduleAtFixedRate. This method's semantics are "based on the start time of the previous task, start every N seconds". If emitter.send() takes more than 1 second due to network issues, the next task starts immediately—two threads writing to the same SseEmitter simultaneously, causing data disorder or even concurrency exceptions.

Safer approach is to use scheduleWithFixedDelay:

// scheduleWithFixedDelay: based on the **end time** of the previous task, start after N seconds

executor.scheduleWithFixedDelay(() -> {
    try {
        emitter.send(SseEmitter.event()
            .name("metric-update")
            .data(data));
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
}, 0, 1, TimeUnit.SECONDS);

Point 4: WebSocket Multi-Instance Deployment — Messages Sent to Thin Air

Suppose you deploy 2 service instances A and B, and a client establishes WebSocket connection with instance A. When @Scheduled triggers push on instance B, B calls convertAndSend—the message only goes to clients connected to B, clients connected to A don't receive it.

Several solutions:

  1. Sticky Session (Simple but inelegant): Load balancer does session persistence based on WebSocket handshake request, same client always routed to same instance.
  2. External Broker (Recommended): Use RabbitMQ or ActiveMQ as STOMP broker, all instance messages relayed through broker:
    registry.enableStompBrokerRelay("/topic")
        .setRelayHost("rabbitmq-host")
        .setRelayPort(61613);
    
  3. Redis Pub/Sub (Lightweight): No STOMP broker, use Redis for cross-instance message broadcasting, manage WebSocket sessions yourself.

Point 5: Connection Leaks — Silent Memory Bombs

Every long connection scheme can leak, just in different ways:

Scheme Leak Point Consequence
Long Polling DeferredResult not timed out, uncleaned CopyOnWriteArrayList entries Thread occupation + memory growth
SSE ScheduledExecutorService not terminated with emitter closure Background threads keep running, increasing
WebSocket Client abnormally disconnects but server unaware (half-open connection) File descriptor exhaustion

Key Protections:

Point 6: Authentication — Where to Put the Token?

SSE's EventSource API doesn't support custom request headers, so JWT token can't go in Authorization header. Two common solutions:

// Option A: token in URL parameter (note HTTPS to prevent leakage)
const es = new EventSource(`/api/sse/stream?token=${jwt}`);

// Option B: POST first to get a temporary ticket, then use ticket for SSE
const ticket = await fetch('/api/sse/ticket', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` }
}).then(r => r.text());
const es = new EventSource(`/api/sse/stream?ticket=${ticket}`);

WebSocket's STOMP protocol can pass auth headers in CONNECT frame; SockJS can also pass token in URL.

Point 7: Where Does Data Come From? — Push Channel ≠ Data Source

This is a problem many newcomers overlook. The 4 schemes in this article solve "how data reaches the frontend", but "how data changes" is another problem. The Demo used a simulator generating random data periodically; in production you face real databases:

Suggestion: Start with application-level events to get the flow working; move to CDC/MQ when data volume is high or decoupling requirements increase.


Summary

  1. Short polling is not original sin—for internal tools, low-frequency data, quick-launch scenarios, it's the most pragmatic choice. Don't complicate things just to show off tech.
  2. SSE is severely underestimated—in "server actively pushes" scenarios, SSE is much simpler than WebSocket, and EventSource API's automatic reconnection saves you so much trouble. Most "real-time dashboards" are fine with SSE.
  3. Choose scheme by connection model first—unidirectional vs bidirectional matters more than you think. Using the wrong direction is digging a hole for yourself.
  4. Distance between Demo and production = the 7 points above—Nginx configured? Async timeout changed? How to broadcast messages across multiple instances? Only when you've considered all these have you truly implemented real-time push.

Complete Source Code

🔗 Gitee Repository: https://gitee.com/gcchech/articles-demo

Module path: realtime-data-push/

Run RealtimeDataPushApplication directly, open browser at http://localhost:8080 to see the visual comparison page of all four schemes—switch between Short Polling/Long Polling/SSE/WebSocket via tabs, observing real-time changes in CPU, QPS, order count, and other metrics.