Winner's Dev Notes

Code, games, and the craft of building better software

Reliable WebSocket Reconnection in Cocos Creator: Heartbeats, Backoff and App Resume

WebSocket code often looks correct in a browser preview but fails in a real game build. Put the app in the background for a few minutes, switch networks, or let a router silently drop an idle TCP connection and the common result is the same: the socket object still exists, but the player can no longer receive game messages.

The fix is not to call new WebSocket() from every error callback. A reliable client needs a small state machine that owns the socket lifecycle:

  • only one active connection attempt;
  • a heartbeat that detects half-open connections;
  • exponential backoff so a server outage does not cause a reconnect storm;
  • a bounded queue for messages produced while offline;
  • a fresh connection when the game returns to the foreground.

This article uses Cocos Creator 3.x and TypeScript. The browser WebSocket API is also used by Cocos Creator’s native JavaScript environment, so the overall design works for Web, Android and iOS builds. Test your target platform, because proxy, TLS and background-network behavior can differ.

The failure mode: an open socket is not proof of a live connection

socket.readyState === WebSocket.OPEN only means the local runtime believes the WebSocket is open. A mobile device can lose connectivity without immediately receiving a TCP close event. The server may already have discarded the connection while the client continues to show OPEN.

That is why reconnecting only in onclose is incomplete. The client must periodically send an application-level ping and require a response within a deadline. If the deadline expires, close the stale socket locally and let the normal reconnect path take over.

A reconnecting WebSocket client

Create ReconnectWebSocket.ts in your project scripts folder. This example uses JSON frames with ping and pong messages; use the equivalent protocol messages if your server already has them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
export enum SocketState {
Idle = "idle",
Connecting = "connecting",
Open = "open",
WaitingToReconnect = "waiting-to-reconnect",
Closed = "closed",
}

type SocketMessage = Record<string, unknown>;

export class ReconnectWebSocket {
private socket: WebSocket | null = null;
private state = SocketState.Idle;
private retryCount = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingMessages: string[] = [];
private manuallyClosed = false;

public onMessage: ((message: SocketMessage) => void) | null = null;
public onStateChange: ((state: SocketState) => void) | null = null;

public constructor(
private readonly url: string,
private readonly heartbeatMs = 20_000,
private readonly pongTimeoutMs = 8_000,
private readonly maxQueuedMessages = 100,
) {}

public connect(): void {
if (this.state === SocketState.Connecting || this.state === SocketState.Open) {
return;
}

this.manuallyClosed = false;
this.clearReconnectTimer();
this.setState(SocketState.Connecting);

try {
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => this.handleOpen(socket);
socket.onmessage = (event) => this.handleMessage(socket, event.data);
socket.onerror = () => {
// onerror is not guaranteed to be followed by useful details.
// onclose owns the reconnect decision.
};
socket.onclose = () => this.handleClose(socket);
} catch (error) {
console.error("WebSocket creation failed", error);
this.setState(SocketState.Idle);
this.scheduleReconnect();
}
}

public send(message: SocketMessage): void {
const serialized = JSON.stringify(message);
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(serialized);
return;
}

// Do not allow a long outage to grow memory forever.
if (this.pendingMessages.length >= this.maxQueuedMessages) {
this.pendingMessages.shift();
}
this.pendingMessages.push(serialized);
this.connect();
}

public close(): void {
this.manuallyClosed = true;
this.clearReconnectTimer();
this.stopHeartbeat();
this.setState(SocketState.Closed);

const socket = this.socket;
this.socket = null;
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1000, "client closed");
}
}

public reconnectNow(): void {
if (this.manuallyClosed) return;
this.retryCount = 0;
this.dropCurrentSocket();
this.connect();
}

private handleOpen(socket: WebSocket): void {
// Ignore callbacks from a connection that was replaced meanwhile.
if (socket !== this.socket) return;

this.retryCount = 0;
this.setState(SocketState.Open);
this.startHeartbeat();

while (this.pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN) {
socket.send(this.pendingMessages.shift()!);
}
}

private handleMessage(socket: WebSocket, rawData: unknown): void {
if (socket !== this.socket || typeof rawData !== "string") return;

let message: SocketMessage;
try {
message = JSON.parse(rawData) as SocketMessage;
} catch {
console.warn("Ignoring non-JSON WebSocket frame");
return;
}

if (message.type === "pong") {
this.clearPongTimer();
return;
}

this.onMessage?.(message);
}

private handleClose(socket: WebSocket): void {
if (socket !== this.socket) return;

this.socket = null;
this.stopHeartbeat();
if (!this.manuallyClosed) this.scheduleReconnect();
}

private scheduleReconnect(): void {
if (this.manuallyClosed || this.reconnectTimer) return;

this.setState(SocketState.WaitingToReconnect);
const exponentialDelay = Math.min(1_000 * 2 ** this.retryCount, 30_000);
const jitter = Math.floor(Math.random() * 500);
this.retryCount += 1;

this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, exponentialDelay + jitter);
}

private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.socket?.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify({ type: "ping", time: Date.now() }));
this.clearPongTimer();
this.pongTimer = setTimeout(() => {
console.warn("WebSocket heartbeat timed out");
this.dropCurrentSocket();
}, this.pongTimeoutMs);
}, this.heartbeatMs);
}

private stopHeartbeat(): void {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
this.clearPongTimer();
}

private dropCurrentSocket(): void {
const socket = this.socket;
this.socket = null;
this.stopHeartbeat();
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
if (!this.manuallyClosed) this.scheduleReconnect();
}

private clearReconnectTimer(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}

private clearPongTimer(): void {
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pongTimer = null;
}

private setState(state: SocketState): void {
if (this.state === state) return;
this.state = state;
this.onStateChange?.(state);
}
}

Server-side heartbeat contract

The sample expects the server to return a pong frame when it receives ping:

1
{ "type": "ping", "time": 1760000000000 }
1
{ "type": "pong" }

Do not treat an arbitrary incoming message as a heartbeat response unless your protocol guarantees frequent server traffic. An explicit pong makes a stalled connection detectable even when the game is otherwise idle.

Connect the client to a Cocos Creator component

Keep the socket owner alive for as long as the game session needs it. A boot scene node marked as persistent is a simple option.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { _decorator, Component, director, game } from "cc";
import { ReconnectWebSocket, SocketState } from "./ReconnectWebSocket";

const { ccclass } = _decorator;

@ccclass("GameSocket")
export class GameSocket extends Component {
private client!: ReconnectWebSocket;

onLoad() {
director.addPersistRootNode(this.node);
this.client = new ReconnectWebSocket("wss://example.com/game");

this.client.onStateChange = (state) => {
console.log("socket state:", state);
// Update a connection indicator here if needed.
};

this.client.onMessage = (message) => {
// Validate message.type and payload before using it in game state.
console.log("server message", message);
};

this.client.connect();
game.on(game.EVENT_SHOW, this.onGameShow, this);
}

onDestroy() {
game.off(game.EVENT_SHOW, this.onGameShow, this);
this.client.close();
}

private onGameShow() {
// The OS may have suspended networking in the background. Reconnect instead
// of trusting the old readyState.
this.client.reconnectNow();
}
}

EVENT_SHOW is a useful recovery point, but it should not be the only one. The heartbeat still protects a player who changes Wi-Fi or loses mobile data while the app remains visible.

Avoid these common reconnection bugs

Creating multiple sockets

If onerror, onclose, a button handler and EVENT_SHOW all call connect() without a guard, several live sockets can exist at once. That causes duplicated messages, duplicated login requests and race conditions. The Connecting and Open checks above ensure one connection owner.

Retrying immediately in a loop

An instant retry loop drains battery and overloads a server exactly when it is least healthy. Exponential backoff with a small random jitter spreads clients across time. Cap the delay so recovery after a long outage is still reasonably quick.

Replaying unsafe messages

Queuing every outbound packet is dangerous. A chat message may be safe to retry; a purchase, reward claim or “use item” command may not be. Add a message identifier and make important server actions idempotent, or mark those messages as non-retryable and show a retry UI instead.

Treating a reconnect as a resumed session

A new WebSocket is a new transport connection. Authenticate again if your protocol requires it, then request a fresh game snapshot or synchronization point. Never assume events missed while offline can be reconstructed from the local client alone.

Test on real devices

Before shipping, test at least these cases on Android and iOS:

  1. Start the game with no network, then restore Wi-Fi.
  2. Switch between Wi-Fi and cellular data while connected.
  3. Background the app for several minutes, then return to it.
  4. Stop the WebSocket server briefly and confirm retry intervals increase.
  5. Send messages while offline and verify only retry-safe frames are replayed.
  6. Force a delayed or missing pong and verify the stale socket is replaced.

The essential rule is simple: the socket object is not your connection state. Your state machine, heartbeat and server synchronization protocol are. Once those responsibilities are explicit, WebSocket behavior in Cocos Creator becomes predictable even on unreliable mobile networks.