WebSocket 心跳怎样避免页面休眠后误判断线
我在处理实时看板时遇到过一种很容易误判的“掉线”:用户把标签页切到后台,回来后页面先显示离线,几秒后又自己恢复。后来顺着日志看,真正异常的不是 WebSocket 立刻断开,而是后台页的定时器晚执行了,前端自己的超时判断先响了。解决思路不是把心跳间隔调得更短,而是把页面可见性、连接状态和服务端确认拆成三层信号。
后台页不要用一次迟到的客户端定时器直接判定断线。隐藏时暂停激进的超时判断,页面恢复时用 visibilitychange 重新建立心跳基线;真正的存活依据交给应用层 heartbeat-ack、WebSocket 的 close/error 事件和服务端超时。
下面按我排查这类问题时采用的顺序展开:先识别误判路径,再建立连接层防护,最后把恢复和审计信息收拢到同一套状态机。示例使用浏览器原生 WebSocket API,配图是静态结构说明图,不是浏览器运行截图或实际运行证据。
先把“页面休眠”和“连接断开”分开
当用户切换标签页、最小化窗口或锁定屏幕时,页面会进入 hidden 状态。MDN 说明,后台页的 setTimeout 等计时器可能被浏览器节流,具体延迟还取决于浏览器、页面活跃度和设备状态。因此,“本地定时器没有按 30 秒执行”只能说明调度晚了,不能单独证明 TCP 或 WebSocket 已经断开。
WebSocket 对象本身有 readyState,连接关闭时会触发 close 事件,发生发送错误时可能触发 error 事件。这些是连接层信号;document.visibilityState 和 visibilitychange 是页面生命周期信号,不能混用。

这一区分带来一个直接影响:页面隐藏后,不要因为本地时间差一次超过阈值就立即把状态改成“断线”;页面重新可见时,也不要无条件再创建一个 WebSocket,否则旧连接和新连接可能同时存在。
用 visibilitychange 重置前台恢复窗口
页面从 hidden 回到 visible 时,最适合做一次连接状态收敛:先取消旧的重连计时器,再检查当前 socket 是否仍处于 OPEN;如果仍然打开,刷新本地心跳基线即可。如果已经 CLOSING 或 CLOSED,就交给唯一的 connect 函数按退避规则建立连接。
下面的代码刻意把“页面恢复”和“服务端确认”分开。代码中的 heartbeat 是应用层消息,不是浏览器脚本可以直接调用的 WebSocket 协议级 ping。
const HEARTBEAT_MS = 30000;
const ACK_TIMEOUT_MS = 90000;
let socket = null;
let heartbeatTimer = 0;
let reconnectTimer = 0;
let reconnectAttempt = 0;
let lastAckAt = 0;
let pageHidden = document.hidden;
function connect() {
// 单连接锁:已有连接或正在连接时,不再创建第二个 WebSocket。
if (socket && (socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)) {
return;
}
socket = new WebSocket("wss://example.com/realtime");
socket.addEventListener("open", () => {
// 连接成功后以当前时刻作为新的确认窗口起点。
reconnectAttempt = 0;
lastAckAt = Date.now();
scheduleHeartbeat();
});
socket.addEventListener("message", (event) => {
// 服务端回执才更新确认时间,不把本地 send 当成存活证明。
if (event.data === "heartbeat-ack") {
lastAckAt = Date.now();
}
});
socket.addEventListener("close", () => {
// close 是连接层信号;页面隐藏时只保留状态,恢复时再加速连接。
stopHeartbeat();
scheduleReconnect();
});
socket.addEventListener("error", () => {
// error 后通常还会收到 close,这里只记录信号,不重复创建连接。
stopHeartbeat();
});
}
function scheduleHeartbeat() {
stopHeartbeat();
heartbeatTimer = window.setInterval(() => {
// 隐藏页的定时器可能延迟,避免用一次迟到直接宣布断线。
if (pageHidden || !socket || socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastAckAt > ACK_TIMEOUT_MS) {
socket.close();
return;
}
socket.send("heartbeat");
}, HEARTBEAT_MS);
}
function stopHeartbeat() {
window.clearInterval(heartbeatTimer);
heartbeatTimer = 0;
}
function scheduleReconnect() {
// 退避并加随机扰动,避免大量页面同时恢复时打满服务端。
if (reconnectTimer || pageHidden) return;
const delay = Math.min(30000, 1000 * 2 ** reconnectAttempt) + Math.random() * 500;
reconnectAttempt += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = 0;
connect();
}, delay);
}
document.addEventListener("visibilitychange", () => {
pageHidden = document.hidden;
if (pageHidden) {
// 隐藏期间停止客户端主动心跳,避免把调度延迟误判成断线。
stopHeartbeat();
return;
}
// 回到前台时先刷新本地窗口,再决定保持连接还是重连。
lastAckAt = Date.now();
if (socket && socket.readyState === WebSocket.OPEN) {
scheduleHeartbeat();
} else {
window.clearTimeout(reconnectTimer);
reconnectTimer = 0;
connect();
}
});
connect();
这里的关键不是“隐藏就永远不重连”,而是隐藏期间不拿一个不可靠的前端计时器做结论。回到前台后,如果 socket 仍为 OPEN,就继续使用它;如果已经关闭,单连接锁和退避计时器会把恢复动作合并起来。
把服务端确认作为心跳依据
客户端 send 成功只表示数据进入发送流程,不等于服务端已经处理,更不等于业务链路仍然健康。更稳妥的约定是客户端发送 heartbeat,服务端返回 heartbeat-ack,客户端只在收到回执时更新 lastAckAt。服务端也应记录每个连接最后一次收到心跳的时间,超时后主动关闭连接。

如果项目使用 JSON 消息,可以把心跳和业务消息统一成带 type 字段的协议;严格限制服务端只回当前连接的确认,避免旧 socket 的回执误更新新连接状态。
function sendHeartbeat() {
// 只在 OPEN 状态发送,避免 InvalidStateError 干扰重连状态机。
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "heartbeat", sentAt: Date.now() }));
}
function isAckExpired(now = Date.now()) {
// 隐藏页恢复后由调用方先重置基线,再进入正常超时判断。
return now - lastAckAt > ACK_TIMEOUT_MS;
}
心跳周期和超时阈值要一起设计。例如心跳约 30 秒一次、连续约 90 秒没有服务端确认才关闭,给一次后台调度延迟留出空间。这个数字不是浏览器标准,应该结合代理空闲超时、服务端连接策略和业务容忍度调整。
重连策略要覆盖恢复、关闭和重复连接
可以把重连条件收敛成四条:
- 收到 close 或 error 后,前台页面按指数退避安排一次重连。
- 页面隐藏时不启动新的激进重连计时器,避免用户切回页面前不断创建连接。
- 页面恢复时先清理旧计时器,再根据 readyState 决定复用或创建连接。
- 每次新建 socket 都给它自己的事件处理上下文,旧连接关闭时不能修改新连接的 lastAckAt。
还要注意浏览器生命周期并不等于可靠的后台运行环境。页面可能被系统挂起、卸载,或者网络在用户离开期间发生变化,所以恢复页面时做一次主动状态同步通常比试图让后台心跳永远准时更可靠。WebSocket 连接也应在页面真正结束时释放,避免影响页面缓存或留下无主连接。
把风险收拢到最小排查清单
| 现象 | 先看什么 | 不要直接下的结论 |
|---|---|---|
| 切后台后出现一次超时 | visibilityState、页面隐藏时长、lastAckAt | 不是“必然断线” |
| 回到前台出现两个连接 | reconnectTimer 与 CONNECTING 状态 | 不是把重连间隔调短 |
| 服务端仍收到连接但业务无响应 | heartbeat-ack 是否由同一连接返回 | 不是只看 readyState === OPEN |
我最后会把页面隐藏与恢复事件、socket 的 readyState 变化、close 的 code/reason、最近一次 heartbeat-ack 时间,以及重连次数放进同一条诊断记录。这样日志才能区分调度延迟、网络断开和服务端超时,不会把“定时器晚了”直接写成“连接已断”。
相关问题
WebSocket 心跳应该用 setInterval 还是 setTimeout?
两者都可能受到页面调度影响。重点是用服务端确认更新存活时间,并在发送前检查 readyState;如果需要根据回执安排下一次心跳,也可以改用递归 setTimeout。
页面切后台后要不要立刻 close?
取决于业务。如果后台不需要实时数据,可以主动暂停并在恢复时重连;如果需要保持会话,就保留连接但不要因单次本地定时器延迟误判。无论哪种选择,都应让服务端超时和前端恢复逻辑保持一致。
为什么 readyState 是 OPEN 仍然可能收不到业务消息?
OPEN 只表示 WebSocket 对象当前处于打开状态,不能替代业务层确认。网络路径、代理、服务端处理和业务订阅都可能另外出问题,所以心跳回执和恢复后的状态同步仍然必要。
WebTransport 怎样选择双向流和 Datagram
- 上一篇
- WebTransport 怎样选择双向流和 Datagram
- 下一篇
- CSS @property 怎样让自定义属性支持平滑动画
-
- 文章 · 前端 | 1小时前 |
- 前端 source map 发布怎样保护源码边界
- 194浏览 收藏
-
- 文章 · 前端 | 1小时前 | 前端 · 实时通信 WebTransport HTTP/3 双向流 Datagram
- WebTransport 怎样选择双向流和 Datagram
- 440浏览 收藏
-
- 文章 · 前端 | 1小时前 |
- Web Streams 怎样处理背压和取消
- 175浏览 收藏
-
- 文章 · 前端 | 1小时前 | VUE watch 响应式 Composition API reactive
- Vue watch 怎样只监听对象的一个字段
- 297浏览 收藏
-
- 文章 · 前端 | 1小时前 |
- React Suspense 数据加载怎样避免瀑布请求
- 368浏览 收藏
-
- 文章 · 前端 | 1小时前 | typescript · javascript · 前端开发 · 类型系统 · TypeScript any 类型收窄 UNKNOWN 类型守卫
- TypeScript unknown 和 any 怎样选择
- 230浏览 收藏
-
- 文章 · 前端 | 1小时前 | 数组 · typescript · 前端开发 · 类型系统 · TypeScript 数组索引 类型收窄 noUncheckedIndexedAccess
- TypeScript noUncheckedIndexedAccess 开启后的数组读取处理方案
- 435浏览 收藏
-
- 文章 · 前端 | 2小时前 | 环境变量 · javascript · 前端工程 · 环境变量 vite 运行时配置 前端构建 import.meta.env
- Vite 环境变量为什么不能在运行时直接修改
- 413浏览 收藏
-
- 文章 · 前端 | 2小时前 |
- Vite 依赖预构建缓存失效怎样定位
- 236浏览 收藏
-
- 文章 · 前端 | 16小时前 | 前端 · Web Components LiT 响应式更新 前端组件 updateComplete
- Lit updateComplete怎样等待组件更新结束
- 425浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 471次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 531次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 550次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 501次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 318次使用
-
- golang基于websocket通信tcpkeepalive研究记录
- 2022-12-23 311浏览
-
- golang实现一个简单的websocket聊天室功能
- 2022-12-28 447浏览
-
- Golang使用WebSocket通信的实现
- 2022-12-26 493浏览
-
- 使用Go语言创建WebSocket服务的实现示例
- 2022-12-28 156浏览
-
- golang websocket 服务端的实现
- 2023-02-16 370浏览
