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:
- 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.
- 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.
- Resource Waste: 99% of requests may pull nothing—the data simply hasn't changed.
Applicable Scenarios
- Data changes infrequently (only changes every few minutes)
- Real-time requirements are low (second-level delay acceptable)
- Client count is controllable (internal system with dozens of users)
- Fast launch is the top priority
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
DeferredResultis an asynchronous return mechanism provided by Spring MVC. It does not block Tomcat worker threads (this is crucial! If you useThread.sleep()to hold the request, the thread pool will quickly exhaust).CopyOnWriteArrayListsuits read-heavy, write-light scenarios. Here the waiters list's notify operation requires traversal, but add/remove operations are relatively few.- Timeout duration should be set reasonably—too short increases request count, too long may be disconnected by gateways/proxies (Nginx default
proxy_read_timeoutis 60s).
Applicable Scenarios
- Higher real-time requirements than short polling
- Need to support older browsers or network environments (HTTP protocol, no additional handshake)
- WeChat Mini Programs (can use long polling as fallback when Mini Programs restrict WebSocket)
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:
- Automatic Reconnection: EventSource API has built-in exponential backoff reconnection strategy, no reconnection code needed.
- Lightweight: Based on HTTP protocol, no need to upgrade connection like WebSocket.
- Proxy Penetration: Pure HTTP, won't be blocked by proxies that don't understand WebSocket.
Limitations:
- Unidirectional Push: Only server→client; client sending data requires a separate POST.
- Connection Limit: Browsers limit HTTP connections to the same domain (about 6 under HTTP/1.1), SSE occupies one. Using HTTP/2 solves this problem.
- No Binary Support:
text/event-streamonly transmits text; binary requires base64 encoding.
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:
- Connection State Management: Manual reconnection needed after client disconnection (
stompClienthasonclosecallback for handling). - Heartbeat Keep-Alive: STOMP has built-in heartbeat configuration, but proxies/load balancers may also have independent timeouts.
- Authentication: WebSocket handshake is an HTTP request; token can be placed in URL or passed during the first
CONNECTframe. - Horizontal Scaling: Multi-instance deployment requires STOMP broker (RabbitMQ/ActiveMQ) for message relay, or Redis pub/sub for cross-instance broadcasting.
- Memory: Each WebSocket connection occupies roughly several KB to tens of KB of memory; 10,000 connections can consume hundreds of MB.
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:
- Sticky Session (Simple but inelegant): Load balancer does session persistence based on WebSocket handshake request, same client always routed to same instance.
- External Broker (Recommended): Use RabbitMQ or ActiveMQ as STOMP broker, all instance messages relayed through broker:
registry.enableStompBrokerRelay("/topic") .setRelayHost("rabbitmq-host") .setRelayPort(61613); - 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:
- SseEmitter: Must clean up scheduled tasks in all three callbacks:
onCompletion,onTimeout,onError - WebSocket: Configure STOMP heartbeat (
registry.setTaskScheduler()), enabling server to detect dead connections - Global: Expose connection count metrics, set alert thresholds
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:
- Simplest:
@Scheduledqueries database every second → high database pressure, not recommended - Application-Level Events: Fire Spring Event on data write →
@EventListenerasync processing → push to frontend. Low coupling, recommended - Database CDC: Use Canal (MySQL) or Debezium to listen to binlog/WAL changes → MQ → push service. Zero intrusion, suitable for existing systems
- Redis Pub/Sub: Writer publishes after DB write → push service subscribes → push to frontend. Lightweight, suitable for small to medium scale
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
- 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.
- 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.
- Choose scheme by connection model first—unidirectional vs bidirectional matters more than you think. Using the wrong direction is digging a hole for yourself.
- 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
RealtimeDataPushApplicationdirectly, 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.