The notification that gave up after a minute

The notification that gave up after a minute

一分钟后就放弃的通知

PopsicleBoat grew browser push notifications recently. The pitch is simple: hear when someone answers you, without handing anyone an email address. Your browser asks permission, a subscription goes to the server, and nothing personal leaves the device. I shipped it, watched the push service accept my first delivery with a tidy 201 Created, and felt good about the whole thing.

PopsicleBoat 最近增加了浏览器推送通知功能。其核心理念很简单:在无需提供电子邮件地址的情况下,当有人回复你时,你就能收到提醒。浏览器会请求权限,订阅信息发送至服务器,且没有任何个人隐私数据离开设备。我发布了该功能,看着推送服务以整洁的“201 Created”状态码接收了我的第一次投递,对此我感觉良好。

Then I replied to one of my own posts from my phone, looked at my laptop, and saw nothing. No banner. No chime. The reply was on the site, the inbox badge lit up, the notification email arrived — every channel working except the one I’d just built. And here’s what made it a good puzzle: every layer of the push stack, inspected on its own, reported success.

随后,我用手机回复了自己的一篇帖子,看向笔记本电脑时,却什么也没看到。没有横幅,没有提示音。回复内容已出现在网站上,收件箱图标亮起,通知邮件也已送达——除了我刚构建的这个渠道外,其他所有渠道都在正常工作。这正是这个难题有趣的地方:推送堆栈的每一层单独检查时,都显示运行成功。

The server sent the notification. The push service accepted it. The subscription was in the database. The service worker was registered. Four green lights, zero banners.

服务器发送了通知。推送服务接收了通知。订阅信息存在数据库中。Service Worker 已注册。四个绿灯全亮,但横幅却一个没出现。

Web push is a relay race with four runners: your server signs a message and hands it to a push service (Google’s, for Chromium browsers), the push service holds it for the browser, the browser wakes a service worker, and the service worker asks the operating system to draw a banner. A dropped baton anywhere shows up the same way — silence at the finish line — and no runner files a report.

Web 推送就像一场有四名选手的接力赛:你的服务器签署一条消息并将其交给推送服务(Chromium 浏览器使用的是 Google 的服务),推送服务为浏览器暂存消息,浏览器唤醒 Service Worker,最后由 Service Worker 请求操作系统绘制横幅。任何环节掉棒,结果都是一样的——终点线一片寂静,且没有任何选手会提交故障报告。

The first dropped baton was mine to find in the browser console. Notification.permission said “default”. Not “granted”, not “denied” — never asked. Somewhere between enabling notifications and testing them, the site’s permission had ended up back at square one — a site-data sweep, probably — while the subscription it had authorized lived on in my database, perfectly valid, pointing at a browser that would no longer show anything. In that state, a page calling new Notification() doesn’t error. It does nothing, silently, which is a bold choice for an API whose entire job is being noticed.

第一个掉棒的地方是我在浏览器控制台中发现的。Notification.permission 显示为 “default”。既不是 “granted”(已授权),也不是 “denied”(已拒绝)——而是从未请求过。在启用通知和测试通知之间的某个时刻,网站的权限回到了原点(可能是因为清理了网站数据),而它授权的订阅信息却依然存在于我的数据库中,完全有效,却指向了一个不再显示任何内容的浏览器。在这种状态下,页面调用 new Notification() 时不会报错。它只是静默地什么都不做,对于一个职责就是“被注意到”的 API 来说,这真是一个大胆的选择。

One re-grant later (“forever,” this time), banners worked. Victory lasted about an hour, until I noticed pushes still vanished whenever the browser wasn’t running.

重新授权一次(这次选了“永久”)后,横幅生效了。胜利只持续了大约一个小时,直到我发现只要浏览器没运行,推送消息依然会消失。

That was the second baton, and it was hiding in a default I’d never questioned. Push services will happily hold a message for a browser that’s closed — that’s the whole point of the relay — but only as long as the message’s time-to-live allows. The library I use sets that TTL to sixty seconds unless told otherwise. Sixty seconds. Close your laptop, get a reply two minutes later, and the push service shrugs and discards it. The 201 it returned was entirely honest: message accepted. Nobody promised kept.

这是第二个掉棒的地方,它隐藏在一个我从未质疑过的默认设置中。推送服务很乐意为关闭的浏览器暂存消息——这正是接力赛的意义所在——但前提是消息的生存时间(TTL)允许。我使用的库将 TTL 默认设置为 60 秒。60 秒。合上笔记本电脑,两分钟后收到回复,推送服务就会耸耸肩将其丢弃。它返回的 201 状态码完全诚实:消息已接收。但没人承诺过消息会被送达。

The fix is one option: send_notification(payload, message, ttl: 86_400). A day. That’s roughly how long “someone answered you” stays worth interrupting someone for; any older and it’s the inbox’s job, which never forgets.

修复方法只有一个选项:send_notification(payload, message, ttl: 86_400)。一天。这大概就是“有人回复你”这一消息值得打断用户的时间长度;如果超过这个时间,那就是收件箱的工作了,收件箱永远不会忘记。

Two lessons sailed home with this one. First: in a layered system where every layer answers “OK,” the bug lives in the gaps between the layers — the permission that reverted between grant and use, the message that expired between accepted and delivered. Debugging meant walking the relay in order and asking each runner not “did you succeed?” but “what did you hand the next runner, and did anyone catch it?”

这次经历让我学到了两点。第一:在一个每一层都反馈“OK”的分层系统中,Bug 往往存在于层与层之间的缝隙中——比如在授权和使用之间被重置的权限,或者在接收和投递之间过期的消息。调试意味着按顺序检查接力过程,询问每一位选手时,不是问“你成功了吗?”,而是问“你把什么交给了下一位选手,对方接住了吗?”

Second: defaults are decisions someone else made about your product. A conservative browser permission model, a sixty-second TTL — both defensible choices, by people who’d never seen my notification settings page and its promise to hear when someone answers you. Keeping that promise meant finding every default standing between the reply and the banner, and overruling the ones that disagreed with it.

第二:默认设置是别人为你产品所做的决定。保守的浏览器权限模型、60 秒的 TTL——这些都是合理的选择,但做出这些选择的人从未见过我的通知设置页面,也没见过我“当有人回复时通知你”的承诺。要信守这个承诺,就意味着要找出横亘在回复和横幅之间的每一个默认设置,并推翻那些与此目标相悖的设置。

The banners arrive now. Even the morning after.

现在,横幅可以正常显示了。即使是第二天早上也能收到。