浏览器后台 Tab 导致 WebSocket 断连问题排查与解决
背景
在使用 gotty 这类基于 WebSocket 的 Web Terminal 工具时,遇到一个问题:
- 浏览器打开页面后,WebSocket 连接一切正常
- 切换到其他浏览器标签页(Tab),停留一段时间
- 再切回原 Tab,发现 WebSocket 已断开
gotty 日志:
2026/01/13 01:06:16 New client connected: 10.42.2.8:41704, connections: 1/0
2026/01/13 01:12:18 Connection closed by client: 10.42.2.8:41704, connections: 0/0更有意思的是:
- 本地调试(localhost / 内网)几乎不会复现
- 通过公网访问时,问题稳定出现
浏览器切换 Tab 的影响
第一反应是怀疑浏览器行为。通过简单验证可以确认:
- 浏览器会对后台页面进行 JS 执行节流 / 暂停(可以通过浏览器的开发者工具,查看WebSocket的数据交互)
- setInterval / setTimeout 的触发频率被大幅拉长,甚至完全暂停(取决于浏览器的行为)
如果 WebSocket 的心跳依赖前端 JS 定时器,那么在后台 Tab 场景下:
心跳不再发送,连接会逐渐变成“空闲连接”
为什么本地不断,公网却会断?
我发现浏览器JS节流/暂停之后,第一时间在电脑本地也跑一个gotty,验证的结果是本地连接,即使浏览器对JS节流/暂停,也不会导致连接中断。因此初步得出一个结论:问题出现在网络环境的差异上。
本地 / 内网访问链路
browser → localhost / LAN → server特点是:
- 没有 NAT
- 几乎不存在 TCP idle timeout
- 即使连接长时间无数据,也不会被回收
因此,本地连接时即使 JS 完全暂停,连接依然“看起来还活着”。
公网访问的真实链路
browser
↓
路由器/防火墙 NAT
↓
运营商
↓
云防火墙 / 负载均衡
↓
server这条链路上的每一层设备,几乎都会对 空闲 TCP 连接设置超时回收机制。
常见的 idle timeout:
- 家用路由器:60–300 秒
- 云负载均衡:60–600 秒
- 移动网络:更短
而这些回收行为通常是 silent drop:
- 不发送 FIN
- 不发送 RST
- 双方都不知道连接已经被中间设备“忘记”
半开连接(Half-open TCP)
在后台 Tab 场景下,典型的故障链路是:
浏览器切后台
↓
JS 心跳暂停
↓
WebSocket 无任何流量
↓
TCP 连接空闲
↓
NAT / 防火墙回收连接
↓
连接变成半开状态此时:
- 浏览器认为连接仍然存在
- 服务端认为连接仍然存在
- 但中间网络设备已经不再转发数据
只有在下一次写数据时,问题才会暴露。
reconnect 有用吗?
gotty 支持 reconnect 机制,使用 --reconnect 参数开启。经过测试,在这个场景下无效。
我查找资料后,reconnect 是客户端重连,可能适用的场景:
服务端主动close
例如:
- 服务端升级/重启
- 超过最大会话时长
服务端进程异常退出
例如:
- panic
- OOM
- SIGKILL
客户端网络短暂中断(可感知)
例如:
- Wi-Fi断开又恢复
- VPN切换
它解决的是:“断了怎么办”,而不是 “保持连接不断”。
服务端 WebSocket Ping
问题的根因可以归结为一句话:
连接在后台 Tab 时没有任何网络流量,导致被中间网络设备回收
因此,正确的解决方向是:
- 不依赖前端 JS
- 由服务端主动制造“最小但持续”的流量
WebSocket 协议本身已经提供了标准答案:
Ping / Pong(RFC 6455)
- 服务端发送 Ping frame
- 浏览器网络栈自动回复 Pong
- 该过程 不依赖 JavaScript 执行
即使页面 JS 被完全暂停,只要连接仍存在,Pong 也会被自动发送。
改造方案
在服务端 WebSocket 建立后,增加三点:
- 定期发送 Ping(例如每 30 秒)
- 设置 PongHandler,刷新 ReadDeadline
- 在 Pong 超时后主动关闭连接,交给 reconnect 兜底
这样可以同时达到:
- 防止 NAT / 防火墙回收连接
- 及时发现真正的死连接
- 与现有 reconnect 机制完美配合
因为 gotty 项目维护者已经不活跃,不管是提ISSUE或是PR,维护者也久久不回复,因此,我在我 fork 的版本中做了修改,修改的代码在这个提交:
COMMIT:https://github.com/chenrizhi/gotty/commit/5578b76f535c9260c53c58907108a52a2994971e
参数取值的取舍
修改的代码有两个常量:serverSidePingInterval、serverSidePongWait,这两个值的取值也不能乱取的。
在不清楚真实网络 idle timeout 的前提下,采用保守策略:
- serverSidePingInterval = 30s
- serverSidePongWait = 90s
经验法则是:
- ping 间隔要 小于任何可能的 NAT idle timeout
- pongWait 至少是 pingInterval 的 2–3 倍
30s / 90s 是一个在公网、NAT、移动网络下都相对安全的折中方案。
最终效果
引入服务端 Ping 后:
- 后台 Tab 长时间停留,连接依然保持
- 公网环境下不再频繁触发断连
- reconnect 只在真正异常时才会发生
总结
- 浏览器后台 Tab 会暂停 JS 执行,这是规范行为
- 公网环境中的 NAT / 防火墙会回收空闲 TCP 连接
- 两者叠加,导致 WebSocket 在后台场景下“莫名其妙断开”
- reconnect 只是补救措施,无法防止断连
- 服务端 WebSocket Ping 才是根治方案