Hardening WebSocket for Real-Time Dashboards: Heartbeats, Resumable Streams, and Idempotent Consumption
WebSocket Stability Optimization for Data Dashboards: Heartbeat Detection, Resumable Transfer, and Message Deduplication
Preface
Previous articles mainly discussed rendering optimization for a large number of map points:
Layered rendering
↓
Aggregated display
↓
Incremental updates
↓
Only update changed points
These optimizations solve:
How to render data onto the map faster after the frontend receives it.
But in real dashboard projects, there is another issue that is more easily overlooked:
How does data reach the frontend stably and with low latency?
Especially in intranet environments, weak network environments, and data dashboard scenarios, we generally want real-time data latency controlled within 200ms. Under normal connections, WebSocket can achieve very low latency.
The real trouble is network fluctuation:
Network jitter
↓
WebSocket disconnects
↓
Frontend reconnects
↓
Messages may be lost during disconnection
↓
Server may resend after reconnection
↓
Frontend may consume duplicates
If this pipeline is not designed well, the dashboard will show:
Drone position jumps
Event point status rollbacks
Duplicate map points
Fluctuating statistics
Sudden full-page refresh flickering
So this article continues the previous line of thought and discusses how WebSocket should be optimized in real-time dashboards.
The focus is not "how to create a WebSocket," but:
How should WebSocket be designed when the network fluctuates, data cannot be lost, and the page cannot flicker?
The final solution is:
Heartbeat detection + Backoff reconnection + Resumable transfer + Message deduplication + Incremental consumption
Problems with the Existing Approach
The original WebSocket wrapper in the project looked something like this:
import io from "socket.io-client";
class SocketManager {
constructor() {
this.socket = null;
}
connect(url, options = {}) {
const defaultOptions = {
path: "/web.socket-new",
autoConnect: true,
transports: ["websocket"],
};
const socketOptions = { ...defaultOptions, ...options };
this.socket = io(url, socketOptions);
return this;
}
disconnect() {
this.socket.disconnect();
}
login() {
if (this.socket) {
this.socket.emit("login", {
token: window.localStorage.getItem("thirdToken"),
clientId: Math.random(),
uavSwitch: true,
});
}
}
on(event, handler) {
if (this.socket) {
this.socket.on(event, handler);
if (event === "connect" && this.socket.connected) {
handler();
}
}
}
emit(event, data) {
if (this.socket) {
this.socket.emit(event, data);
}
}
}
const socketManager = new SocketManager();
export default socketManager;
Usage in the page:
mounted() {
socketManager.connect("http://shangcheng.cldeye.com");
socketManager.on("connect", () => {
socketManager.login();
});
socketManager.on("dockMessage", (data) => {
this.hangarData = data.message;
this.initHangarMarker();
});
socketManager.on("uavMessage", (data) => {
this.uavData = data.message;
this.initUavMarker();
});
}
This version works, but has several hidden dangers in real-time dashboards.
Pitfalls Encountered
Simple Reconnection
The most intuitive approach is:
socket.onclose = () => {
connect();
};
Or immediately calling connect again after disconnection.
The problem with this approach is:
If the network is persistently unstable, the frontend will reconnect frantically.
When dozens of dashboard clients are online simultaneously, the moment the network recovers, all clients reconnect together, putting immense pressure on the server.
So reconnection cannot be at fixed intervals, and certainly not immediate; it should use:
Exponential backoff + random jitter
For example:
1st reconnect: ~1s
2nd reconnect: ~2s
3rd reconnect: ~4s
4th reconnect: ~8s
Maximum not exceeding 30s
Add a bit of random jitter each time
This prevents all clients from hitting the server at the same time.
Full Refresh
Some projects re-request a full set of data after a successful reconnection:
WebSocket disconnects
↓
Reconnection succeeds
↓
Re-fetch all drone, airport, event data
↓
Clear the map
↓
Re-render
This is fine when data volume is small.
But if there are thousands, or even tens of thousands of points on the map, you will see:
Page flickering
Map point reconstruction
Icon reloading
Aggregation recalculation
Users see dashboard jitter
We have already implemented map incremental updates, so WebSocket should not trigger a full refresh after reconnection.
The correct approach should be:
After reconnection, tell the server: what was the last message I consumed?
Server only resends messages missed during the disconnection period
Frontend continues merging via incremental patches
Message Loss
During WebSocket disconnection, the server may still generate data:
t1: Frontend receives seq = 100
t2: Network disconnects
t3: Server generates seq = 101, 102, 103
t4: Frontend reconnects successfully
Without resumable transfer, the frontend likely starts receiving from the latest message:
Frontend next receives seq = 104
Then the intermediate:
101, 102, 103
are permanently lost.
On the map, this might manifest as:
Drone missing a segment of its trajectory
Event status not updated
Airport status stuck at old value
Statistics inconsistent with real data
Duplicate Consumption
After reconnection, to ensure no message loss, the server might resend starting from a certain seq.
For example, the frontend last confirmed consumption up to:
seq = 100
For safety, the server starts resending from:
seq = 98
Thus the frontend receives again:
98, 99, 100
If the frontend lacks deduplication, it will consume duplicates.
For dashboards, the risk of duplicate consumption is high:
The same event added twice
The same statistic count incremented twice
The same drone trajectory point inserted twice
The same delete message executed twice, causing state anomalies
So the frontend must possess:
Message-level deduplication capability
Final Selection
I will ultimately optimize WebSocket into this set:
Socket.IO connection layer
↓
Business heartbeat detection
↓
Exponential backoff reconnection
↓
Carry lastSeq during login
↓
Server resends messages after lastSeq
↓
Frontend deduplicates by messageId / seq
↓
Converts to map patches by business type
↓
Hands off to map incremental update logic
One point to note here.
The project uses:
socket.io-client
It's not native WebSocket, but Socket.IO.
Socket.IO comes with connection management, underlying ping/pong, auto-reconnection, etc. So why does the frontend still need heartbeat and resumable transfer?
The reason is:
Socket.IO's heartbeat mainly proves the connection is alive;
Business heartbeat proves the data link and business service are normal;
Socket.IO's reconnection only restores the connection;
Resumable transfer restores business messages lost during disconnection.
So these are not the same thing.
My choice is:
Continue using Socket.IO for the connection layer
Supplement business stability ourselves
This has the lowest refactoring cost and is most suitable for existing projects.
How to Design the Message Protocol
To implement resumable transfer and message deduplication, the data pushed by the backend cannot just be:
{
message: {}
}
It's best to unify into this structure:
{
messageId: "uav_1720000000000_1001",
seq: 1001,
type: "uavMessage",
timestamp: 1720000000000,
action: "upsert",
message: {
id: "uav-001",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
}
Several fields are critical.
messageId:
Unique message ID, used for deduplication.
seq:
Server-side incrementing sequence number, used to determine breakpoints.
type:
Business message type, e.g., uavMessage, dockMessage, eventMessage.
action:
Whether the current message is an add, update, or delete.
message:
The actual business data.
If the backend temporarily cannot provide messageId, you can initially use:
const messageId = `${type}:${seq}`;
But it's more recommended for the backend to generate it uniformly, as the backend knows the global order and uniqueness of messages best.
Why seq is Needed
Many students ask:
If messageId handles deduplication, why is seq still needed?
Because they solve different problems.
messageId solves:
Have I consumed this message before?
seq solves:
Where have I consumed up to?
Where should I continue from after disconnection?
Is there a gap in the messages?
For example:
Frontend last received seq = 100
Next message received directly is seq = 104
This indicates potential loss of:
101, 102, 103
At this point, the frontend can trigger compensation:
Request messages after 100 from the server
So in real-time systems, I generally require the server to push messages with an incrementing seq.
What State Does the Frontend Need to Save
The frontend needs to save at least 4 types of state.
Connection State
connected: false
connecting: false
manualClose: false
reconnectTimes: 0
Used to determine if currently connected, if it was a user-initiated close, and if reconnection is needed.
Breakpoint State
lastSeq: 0
Updated every time a message is successfully consumed:
this.lastSeq = Math.max(this.lastSeq, message.seq);
To allow resumption even after page refresh, it can be written to localStorage:
localStorage.setItem("screen:lastSeq", String(this.lastSeq));
Deduplication State
messageIdSet: new Set()
messageIdQueue: []
The Set cannot grow indefinitely, so an LRU window is needed.
For example, only keep the last 5000 message IDs:
rememberMessageId(messageId) {
if (this.messageIdSet.has(messageId)) {
return false;
}
this.messageIdSet.add(messageId);
this.messageIdQueue.push(messageId);
if (this.messageIdQueue.length > this.maxMessageCache) {
const expiredId = this.messageIdQueue.shift();
this.messageIdSet.delete(expiredId);
}
return true;
}
Business Data State
This is from the previous article:
markerDataMap
markerEntityMap
markerSnapshotMap
WebSocket should not directly clear and redraw the map, but should convert messages into patches:
{
upserts: [],
removeIds: []
}
Then hand them to the map component:
this.$refs.cesiumMap.applyMarkerPatch(patch);
How to Modify the Frontend SocketManager
Below is a wrapper version more suitable for dashboards.
It mainly does several things:
1. Fix clientId on connection, avoiding generating a new identity on every login
2. Carry lastSeq during login, letting the server know where to resume sending
3. Add business heartbeat to check if the business link is normal
4. Add exponential backoff reconnection to avoid frantic reconnection during network loss
5. Add message deduplication to avoid duplicate consumption from reconnection resends
6. Add off to prevent listener residue after page destruction
Code example:
One compatibility point to note: the current project pages already call login() manually after connect, so the wrapper layer does not auto login() to avoid duplicate logins on the same connection. If all pages later converge to internal login within SocketManager, login() can be moved into the connect callback.
import io from "socket.io-client";
const LAST_SEQ_KEY = "screen:last-message-seq";
const CLIENT_ID_KEY = "screen:client-id";
function getClientId() {
let clientId = window.localStorage.getItem(CLIENT_ID_KEY);
if (!clientId) {
clientId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(CLIENT_ID_KEY, clientId);
}
return clientId;
}
class SocketManager {
constructor() {
this.socket = null;
this.url = "";
this.options = {};
this.clientId = getClientId();
this.lastSeq = Number(window.localStorage.getItem(LAST_SEQ_KEY) || 0);
this.manualClose = false;
this.reconnectTimes = 0;
this.reconnectTimer = null;
// Heartbeat mechanism
this.heartbeatTimer = null;
this.lastPongAt = Date.now();
this.heartbeatInterval = 10000;
this.heartbeatTimeout = 30000;
this.enableBusinessHeartbeat = false;
this.pingEventName = "clientPing";
this.pongEventName = "serverPong";
// Mainly for data deduplication
this.maxMessageCache = 5000;
this.messageIdSet = new Set();
this.messageIdQueue = [];
this.eventHandlers = new Map();
}
connect(url, options = {}) {
this.url = url;
this.options = options;
this.manualClose = false;
this.clearReconnectTimer();
this.createSocket();
return this;
}
createSocket() {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
const defaultOptions = {
path: "/web.socket-new",
transports: ["websocket"],
autoConnect: true,
// Disable Socket.IO's default infinite reconnection here, uniformly use business backoff reconnection.
// You can also keep Socket.IO reconnection, but don't write another set of immediate reconnection manually.
reconnection: false,
};
const socketOptions = {
...defaultOptions,
...this.options,
};
this.enableBusinessHeartbeat = Boolean(
socketOptions.enableBusinessHeartbeat
);
this.pingEventName = socketOptions.pingEventName || this.pingEventName;
this.pongEventName = socketOptions.pongEventName || this.pongEventName;
delete socketOptions.enableBusinessHeartbeat;
delete socketOptions.pingEventName;
delete socketOptions.pongEventName;
this.socket = io(this.url, socketOptions);
this.bindBaseEvents();
this.bindRegisteredEvents();
}
bindBaseEvents() {
this.socket.on("connect", () => {
this.reconnectTimes = 0;
this.lastPongAt = Date.now();
this.startHeartbeat();
});
this.socket.on("disconnect", () => {
this.stopHeartbeat();
if (!this.manualClose) {
this.scheduleReconnect();
}
});
this.socket.on("connect_error", () => {
this.stopHeartbeat();
if (!this.manualClose) {
this.scheduleReconnect();
}
});
// Business heartbeat response. Event name needs to be agreed with backend; not recommended to directly occupy underlying ping/pong.
this.socket.on(this.pongEventName, () => {
this.lastPongAt = Date.now();
});
}
bindRegisteredEvents() {
this.eventHandlers.forEach((handlers, event) => {
handlers.forEach((handler) => {
this.socket.on(event, handler);
});
});
}
login() {
if (!this.socket) {
return;
}
this.socket.emit("login", {
token: window.localStorage.getItem("thirdToken"),
clientId: this.clientId,
uavSwitch: true,
// Key point: tell the backend which message I have consumed up to.
lastSeq: this.lastSeq,
});
}
startHeartbeat() {
if (!this.enableBusinessHeartbeat) {
return;
}
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (!this.socket || !this.socket.connected) {
return;
}
const now = Date.now();
if (now - this.lastPongAt > this.heartbeatTimeout) {
this.socket.disconnect();
this.scheduleReconnect();
return;
}
this.socket.emit(this.pingEventName, {
clientId: this.clientId,
lastSeq: this.lastSeq,
timestamp: now,
});
}, this.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
if (this.reconnectTimer) {
return;
}
const baseDelay = 1000;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** this.reconnectTimes, maxDelay);
const jitter = Math.floor(Math.random() * 1000);
this.reconnectTimes += 1;
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
this.createSocket();
}, delay + jitter);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
on(event, handler) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event).add(handler);
if (this.socket) {
this.socket.on(event, handler);
}
}
off(event, handler) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(handler);
}
if (this.socket) {
if (typeof this.socket.off === "function") {
this.socket.off(event, handler);
} else {
this.socket.removeListener(event, handler);
}
}
}
emit(event, data) {
if (this.socket) {
this.socket.emit(event, data);
}
}
disconnect() {
this.manualClose = true;
this.stopHeartbeat();
this.clearReconnectTimer();
this.eventHandlers.clear();
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
}
rememberMessage(message) {
const messageId = message.messageId || `${message.type}:${message.seq}`;
if (!messageId) {
return true;
}
if (this.messageIdSet.has(messageId)) {
return false;
}
this.messageIdSet.add(messageId);
this.messageIdQueue.push(messageId);
if (this.messageIdQueue.length > this.maxMessageCache) {
const expiredId = this.messageIdQueue.shift();
this.messageIdSet.delete(expiredId);
}
if (Number(message.seq) > this.lastSeq) {
this.lastSeq = Number(message.seq);
window.localStorage.setItem(LAST_SEQ_KEY, String(this.lastSeq));
}
return true;
}
}
const socketManager = new SocketManager();
export default socketManager;
This code doesn't necessarily need to be copied exactly, but the core idea is:
Connection is just the first step;
What really matters is how to restore the correct data state after disconnection.
How to Modify Page Consumption
Originally, pages consumed messages directly:
socketManager.on("uavMessage", (data) => {
this.uavData = data.message;
this.initUavMarker();
});
The problem with this approach is:
Re-initialize the marker every time a message is received.
If message frequency is high, the map will be frequently rebuilt.
After optimization, it should become:
mounted() {
socketManager.connect("http://shangcheng.cldeye.com");
this.handleSocketConnect = () => {
socketManager.login();
};
this.handleUavMessage = (packet) => {
if (!socketManager.rememberMessage(packet)) {
return;
}
const patch = this.createMarkerPatchFromSocket(packet);
if (patch) {
this.$refs.cesiumMap.applyMarkerPatch(patch);
}
};
this.handleDockMessage = (packet) => {
if (!socketManager.rememberMessage(packet)) {
return;
}
const patch = this.createMarkerPatchFromSocket(packet);
if (patch) {
this.$refs.cesiumMap.applyMarkerPatch(patch);
}
};
socketManager.on("connect", this.handleSocketConnect);
socketManager.on("uavMessage", this.handleUavMessage);
socketManager.on("dockMessage", this.handleDockMessage);
},
beforeDestroy() {
socketManager.off("connect", this.handleSocketConnect);
socketManager.off("uavMessage", this.handleUavMessage);
socketManager.off("dockMessage", this.handleDockMessage);
}
Then convert different business messages into unified patches:
methods: {
createMarkerPatchFromSocket(packet) {
const data = packet.message;
if (!data) {
return null;
}
if (packet.action === "delete") {
return {
removeIds: [data.id],
};
}
return {
upserts: [
{
id: data.id,
type: packet.type === "uavMessage" ? "uav" : "airport",
status: data.status,
longitude: data.longitude,
latitude: data.latitude,
height: data.height,
raw: data,
},
],
};
},
}
Thus WebSocket and map rendering are connected:
WebSocket message
↓
Message deduplication
↓
Convert to patch
↓
Map applyMarkerPatch
↓
Only update changed points
Why Not Full Refresh After Reconnection
Full refresh after reconnection seems the safest:
Since the connection was broken, might as well re-fetch everything.
But its problems are poor experience and performance.
What map dashboards fear most is:
Clear and redraw
Because users can clearly see point flickering.
The resumable transfer approach is:
I don't assume the frontend state is invalid.
I only fill in the messages missed during disconnection.
This preserves the current map state, only adding the missing changes.
The previously implemented markerDataMap and applyMarkerPatch perfectly handle this:
Map state before disconnection
↓
Resend missing patches after reconnection
↓
Frontend merges patches
↓
Map partial update
The page won't flicker, and data catches up.
Why Business Heartbeat is Needed
Socket.IO has its own ping/pong, so is a business heartbeat still necessary?
I think it's necessary in dashboard scenarios.
Because the underlying connection being alive only indicates:
The connection between client and Socket.IO service is still there.
But the business link might still have issues:
Business service is stuck
Message queue is backing up
Server has stopped pushing business data
Proxy layer connection is alive but backend is unavailable
So the business heartbeat should ideally carry:
{
clientId,
lastSeq,
timestamp
}
It can do at least three things:
1. Let the server know the client is still alive
2. Let the server know where the client has consumed up to
3. Let the frontend know if the business link has been unresponsive for too long
If no business pong is received for a continuous period exceeding a threshold, the frontend can actively disconnect and reconnect.
This is more controllable than waiting for the browser or underlying WebSocket to detect disconnection on its own.
Why Backoff Reconnection is Needed
The problem with simple reconnection is:
All clients reconnect together after disconnection.
This causes instantaneous server pressure.
The core of backoff reconnection is:
The more failures, the slower the reconnection.
The core of random jitter is:
Don't let all clients reconnect in the same second.
So it's recommended:
const delay = Math.min(1000 * 2 ** reconnectTimes, 30000);
const jitter = Math.floor(Math.random() * 1000);
Final wait time:
delay + jitter
This way, when the network recovers, clients reconnect in a dispersed manner, making the server more stable.
Why Message Deduplication is Needed
Resumable transfer usually brings a side effect:
To ensure no loss, the server might resend a few extra messages.
For example, the frontend last consumed up to:
seq = 100
The server might start resending from:
seq = 98
At this point, receiving duplicates of 98, 99, 100 is normal.
The frontend cannot assume the server will never push duplicates.
A more robust principle is:
Server ensures at-least-once delivery;
Frontend ensures idempotent consumption.
That is:
Messages can arrive multiple times, but duplicate messages must not take effect multiple times.
This is the role of messageIdSet.
if (!socketManager.rememberMessage(packet)) {
return;
}
Duplicate messages are simply skipped.
How to Combine with Map Incremental Updates
WebSocket optimization is not isolated.
It should serve map rendering optimization.
Previously, the map transitioned from full updates to:
Maintain markerDataMap
↓
Receive patch
↓
Merge patch
↓
Only update changed markers
So WebSocket messages should also be designed as patches.
For example, backend pushes drone position change:
{
messageId: "uav_1001",
seq: 1001,
type: "uavMessage",
action: "upsert",
message: {
id: "uav-001",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
}
Frontend converts to:
{
upserts: [
{
id: "uav-001",
type: "uav",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
]
}
If it's a delete:
{
messageId: "uav_1002",
seq: 1002,
type: "uavMessage",
action: "delete",
message: {
id: "uav-001"
}
}
Frontend converts to:
{
removeIds: ["uav-001"]
}
Finally handed to the map:
this.$refs.cesiumMap.applyMarkerPatch(patch);
The overall pipeline is:
Backend pushes seq message
↓
Frontend checks for duplicates
↓
Update lastSeq
↓
Convert to marker patch
↓
Merge markerDataMap
↓
Re-evaluate current layer aggregation/detail mode
↓
Aggregation layer recalculates aggregation
↓
Detail layer only updates changed points
Thus, WebSocket stability optimization directly translates to map experience.
What the Backend Needs to Cooperate On
This solution cannot be fully closed-loop with just the frontend.
The backend at least needs to support:
1. Each message has a globally incrementing seq
2. Each message has a unique messageId
3. Client can carry lastSeq during login
4. Server can resend messages after lastSeq
5. Server retains a message cache for a period
6. Server records client consumption progress upon receiving ack or heartbeat
A simple server-side logic could be:
Client login(lastSeq)
↓
Server queries messages where seq > lastSeq
↓
Resends in order
↓
Then continues pushing real-time messages
Message cache can retain:
Last 5 minutes
Last 100,000 messages
Partitioned by business topic
The specific retention duration depends on business data volume and disconnection recovery requirements.
Whether to Implement ack
If requirements are stricter, ack can be added.
After successful frontend consumption:
socketManager.emit("ack", {
clientId: socketManager.clientId,
seq: socketManager.lastSeq,
});
Upon receiving ack, the server records which seq this client has consumed up to.
With ack, the server can more accurately know:
The client received the message
The client processed it successfully
Where the client can resume from
But ack also increases communication volume.
If message frequency is high, it's not recommended to ack every single message; batch ack can be used:
Ack every 1 second
Ack every 100 messages consumed
Carry lastSeq in heartbeat ack
In dashboard scenarios, I prefer:
Heartbeat carries lastSeq
Important business messages ack individually
This reduces network pressure while ensuring critical data reliability.
Final Effect
Before optimization:
WebSocket disconnects
↓
Immediate reconnection
↓
Re-fetch full data after successful reconnection
↓
Map flickers
↓
Messages may be lost during disconnection
↓
Resent messages may be consumed multiple times
After optimization:
WebSocket disconnects
↓
Stop heartbeat
↓
Exponential backoff reconnection
↓
Reconnection login carries lastSeq
↓
Server resends missing messages
↓
Frontend messageId deduplication
↓
Messages converted to patches
↓
Map incremental update
↓
Page doesn't flicker, data catches up
Summary
WebSocket in real-time dashboards is not just about connecting.
The real difficulty is:
What if the connection drops?
What about data missed during reconnection?
What about duplicates caused by server resends?
How to avoid full page refresh flickering?
The core of this optimization is not making WebSocket more complex, but upgrading it from a "connection tool" to a "reliable real-time data channel."
The final solution can be summarized as:
Heartbeat detection: Identify fake connections and business link anomalies
Backoff reconnection: Avoid frantically hitting the server during network loss
Resumable transfer: Fill in missing messages after reconnection
Message deduplication: Avoid duplicate consumption of resent messages
Incremental patches: Avoid full map refresh after reconnection
For map dashboards, this WebSocket optimization complements the previous point incremental updates.
The frontend should not re-initialize the entire map after reconnection, but should let real-time messages continue through:
Deduplication
↓
Merge
↓
Diff
↓
Partial update
This simultaneously ensures:
Low latency
Stability
No message loss
No duplicate consumption
No page flickering
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Great article, thanks for sharing
Thanks for the acknowledgment, boss