提供案例
billflare.

出站 WebSocket 不会休眠

Fabrizio Lamanna(@fabriziolams)讲述在 Cloudflare 上做 Twitch 抽奖工具时发现:Durable Objects 能让入站 WebSocket 休眠,出站的不行,常开的出站聊天连接会让对象一直留在内存里并持续计费;他把这条连接移到了 Containers。

适用产品
Durable ObjectsContainersWorkers
来源
作者经验𝕏 文章
作者
Fabrizio Lamanna(@fabriziolams)

要点

  • 他的工具要在整个抽奖期间保持连着聊天室,从九十秒到接近一小时。Worker 撑不住这样的连接;Durable Object 能持有 WebSocket 并让空闲连接休眠,但休眠只适用于入站连接。连向 Twitch 的出站 socket 永远不符合条件,任何让它保持醒着的东西(持有的 socket 或轮询循环)都会让整个对象一直留在内存里、全天计费。
  • 他还遇到放在 Durable Object 里的出站连接在真实直播中二三十分钟后不再收到消息、却仍显示为打开的情况;这个时间来自他自己的日志。他引用了 workerd 中仍未解决的、请求出站 WebSocket 休眠和 keepalive ping 的 issue。
  • 他的设计:聊天客户端作为一个长期运行的 Node 进程跑在 Container 里;一个 Durable Object 把消息分发给每场抽奖各自的 Durable Object,后者通过可休眠的入站 WebSocket 向面板和直播叠加层推送更新。低档位按频道名哈希共用容器,大频道独占一个容器。
  • 他的教训:按工作选工具(Workers 处理请求响应,Durable Objects 管状态和向浏览器扇出,Containers 负责常开的出站连接);并留意 WebSocket 的方向:入站连接空闲时可休眠、不花钱,出站连接不行。
  • 官方文档(2026-10-16 复核):只有 Durable Object 作为 WebSocket 服务端时才支持休眠,出站 WebSocket 不会休眠;出站 WebSocket 和 TCP socket 也会阻止对象被逐出,不能休眠的空闲对象会继续产生时长费用。

原始来源

  1. 发布

    Fabrizio Lamanna(@fabriziolams)讲述在 Cloudflare 上做 Twitch 抽奖工具时发现:Durable Objects 能让入站 WebSocket 休眠,出站的不行,常开的出站聊天连接会让对象一直留在内存里并持续计费;他把这条连接移到了 Containers。

    Fabrizio Lamanna

    @fabriziolams · 𝕏 原帖

    https://t.co/cBtioX6O30
    阅读全文归档于

    Fabrizio Lamanna · @fabriziolams

    Nothing on the Edge Wants to Stay Awake

    How this started I watch a lot of Twitch with the stream parked in a corner of a second monitor while I work. One evening a streamer I like was running a giveaway, the type where you type a word in chat and they draw a winner live. Halfway through, it broke. The entrant counter on the overlay froze, chat kept flying past with everyone spamming the keyword, and not one of those messages was landing. The streamer refreshed, lost everyone who'd already entered, started the whole thing over, and spent the next minute apologising while half of chat decided the draw was rigged. I work in tech, mostly around the cloud, so the way it failed nagged at me more than it should have. The page hadn't crashed. The overlay was right there, counter and all, just not hearing chat anymore. And I had a hunch about why. That smells like a connection that quietly died without telling anyone, which is a miserable kind of bug to chase. A few weekends later I had a working version of Dibs, a giveaway tool for Twitch, Kick and YouTube that runs on Cloudflare. This is the story of that frozen counter. The actual hard part Most of a giveaway is easy to build. The annoying part hides in the word "continuously." From the moment a giveaway opens until the draw, which might be ninety seconds or the better part of an hour, something has to stay glued to chat the whole time, reading messages and bumping the counter whenever one matches the keyword. So the real requirement is boring. Keep one connection open for an hour. And that, on Cloudflare Workers, is the thing you can't do. I should get the obvious objection out of the way. A normal server doesn't have this problem at all. Rent a small VM, run a process on it, and it'll hold a chat socket open for as long as you like, because that's the whole job of a server. I'd bet most giveaway tools do exactly that and never think twice about it. I went and found this problem on purpose. I wanted Dibs to live on the edge, on Cloudflare, sitting close to whoever's playing and scaling down to nothing when nobody is. The tax on that choice is that "keep a connection open" stops being free. Everything below is how I paid it without giving up the edge. Why the obvious version doesn't work A Worker spins up to handle a request and disappears the moment it's replied. That request-and-done shape is why Workers are cheap and scale to nothing when idle, and it's also why there's no room in one to keep a chat connection breathing. You respond, and whatever you were holding gets collected. (waitUntil buys a handful of seconds after the response. I needed an hour.) The reflex here is "use a Durable Object," and it's half right. Durable Objects do a lot of the work in Dibs. One is a long-lived instance with its own storage, it can hold WebSockets, and it can hibernate the idle ones so they cost nothing while they sit there. For pushing live counts out to a dashboard that's a great fit, and it's the job we hand it. The part I didn't register for far too long: hibernation only runs one direction. It hibernates the connections coming in. The one I actually cared about, the socket reaching out to Twitch, never qualified. Cloudflare even spells it out, "Outgoing WebSockets do not hibernate," and I'd read that exact line, nodded, and somehow filed it under not-my-problem until it very much was. A Durable Object makes a fine server, happy to accept connections from a thousand browsers. An outbound chat connection is the opposite animal. It has to stay awake the entire giveaway, and anything keeping it awake, a held socket or a polling loop, keeps the whole object resident in memory and on the bill. You end up paying around the clock for the privilege of working against the grain of the platform. The bug, for real Our first cut did precisely the wrong thing and kept the chat connection inside a Durable Object. In testing, perfect. On an actual stream, it reproduced the exact bug I'd been chewing on. Twenty, thirty minutes in, the connection would go quiet. It didn't crash and it didn't drop. It just stopped handing us messages while the socket still reported open and every health check stayed green. Twitch had gone silent and nothing told us. I went hunting for someone else to blame, and mostly learned that this is the ambient reality of holding an outbound socket at the edge. The documented behaviour is that idle WebSockets get closed unless you keep them warm with your own ping/pong heartbeat, which is the obvious first thing to reach for. Outbound sockets are second-class here more broadly: there's an open issue (workerd#4864) asking for hibernation support for outgoing WebSockets, and Cloudflare's own answer on it is that supporting it would take "some deep architectural changes." There's another open one (workerd#3664) whose entire ask is to let Workers send keepalive pings at all. I'll be straight with you: I never found a report matching my exact symptom, going silent but staying connected after twenty-odd minutes. That number is mine, off my own logs, not anything Cloudflare publishes. But the conclusion didn't need a bug number on it. An always-on outbound socket just isn't the job Workers or Durable Objects signed up for. Giving the connection a real home A cleverer Durable Object was never going to save this. The chat client was a different kind of job and wanted a different kind of compute. Cloudflare Containers let you run an ordinary long-lived process, a real Docker image, right next to your Workers. Cloudflare's own framing is roughly: reach for a Worker when you want something tiny and infinitely scalable, reach for a Container when you need it to just keep running. A container doesn't get torn down at the end of a request and it isn't killed on a timer. It sits there with real socket libraries and proper reconnect logic, holding its connections. So the chat client became a container. One unremarkable Node process that boots, opens its sockets, and stays up for as long as a giveaway is live, whether that's twenty minutes or the rest of the afternoon. Nothing about it is trying to hibernate, so nothing goes quiet. It sat alongside the Workers and Durable Objects rather than replacing them. On Cloudflare you reach a container through a Durable Object anyway, so the Workers side stayed the front door and the traffic cop, and the container took the one job it's actually suited to: keeping the line open. How the pieces fit Here's the trip a single chat message makes, from someone typing in Twitch to the count moving on the streamer's overlay. The container has to speak three languages, because every platform serves chat differently. Twitch gives you a tidy EventSub WebSocket you subscribe to per channel. YouTube has no live push at all, so you sit there polling its API on whatever interval it feels like handing back. Kick is a story of its own, and I'll get to it. The process takes all three and flattens them into one message shape, so nothing downstream has to care where a line came from. The ChatHub is a switchboard, a single Durable Object that knows which giveaways care about which channel. A message lands, it looks up who's listening, and forwards it on. If one channel happens to have two giveaways going at once, they ride the same upstream connection instead of opening a second. Then there's one Durable Object per giveaway. The state is small and it changes constantly, so an object-per-giveaway falls out naturally, and Cloudflare routes to it by id without me sharding anything by hand. That object keeps the entrant list, applies the keyword and entry rules, and streams updates to the dashboard and overlay over the hibernating WebSockets. That's the incoming side, where hibernation earns its keep, the mirror image of the outbound socket that started this whole detour. When a channel is too big to share One container can comfortably hold a lot of channels at once, so on the free and pro tiers the containers are pooled and shared. A channel gets pinned to one of them by a stable hash of its name, which keeps its subscription in a single place instead of scattering it across instances, and lets the pool spread out as it grows. For almost everyone that's invisible, and it should be. Big channels are the exception we had to design around. When chat is a solid wall of text, that one firehose can crowd whoever else happens to share the instance. So the higher tiers don't share at all. A partner-level streamer gets a container keyed to their own account, holding their chat and nothing else. Same pipeline as everyone else underneath, the only difference being who else is on the box, which for them is no one. It started as a way to keep one giant channel from starving the rest, and it turned into the cleanest line between the tiers: pay more, stop sharing. About Kick When I started, my mental model was "Kick has no real API, so you tap their public Pusher socket." That used to be true. It isn't anymore. Kick shipped an official API back in March 2025, and it can read chat by posting a chat.message.sent webhook to a server you register, rather than over a socket. Dibs still uses Kick's undocumented Pusher feed, mostly because it drops neatly into the same always-on socket setup as Twitch, but I want to be straight that this is the unofficial route now, and a reverse-engineered one that can break whenever Kick feels like changing something. If I were starting today I'd at least weigh the official webhook. The other half: a draw nobody has to take on faith Dibs exists for the draw. A tool that just says "trust me, it was random" falls apart the moment a streamer's friend wins and chat starts howling, which is roughly the scene I'd watched. So the draw is built to be checked, not trusted. It goes in three steps. Before the draw, the server picks a random secret, the seed, and puts the fingerprint of it, sha256(seed), on the overlay for everyone to see. From there it can't quietly swap the seed, because the fingerprint would change and everyone already saw the old one. At the draw it reveals the real seed, and the winners come straight out of a function of that seed, with no separate random generator tucked away anywhere. Afterwards the giveaway gets a public proof page that lists, per draw, the fingerprint, the revealed seed, and who won. The part anyone can check with nothing but a hash function is this: take the revealed seed, hash it, and confirm it matches the fingerprint that went up before the draw. That's what rules out a seed swapped after the entries were in. The selection itself is deterministic too, so for a standard draw you can feed the seed and the entrant list back through the same function and land on the same names. (Paid tiers can layer on live filters like follower-only or a minimum account age, and those hit the platform APIs at draw time, so for those draws the reproducible bit is the seeded ordering rather than the final pass or fail. And yes, subscribers can be weighted a little heavier by tier and tenure. That's a product call, and it's deliberately kept out of the public proof.) A few things I'd pass on If you're doing real-time on Cloudflare, the thing I keep relearning is to match the job to the tool. Workers for request-in, response-out. Durable Objects for state and for fanning data out to browsers, which is what they're built for. Containers for the always-on outbound connection that fits neither. I lost a couple of weekends trying to make a Durable Object behave like a 24/7 chat client before that sank in. The smaller lesson, and the one that actually drew blood: notice which way a WebSocket points. Connections coming in from browsers hibernate and cost nothing while they idle. Connections you hold open going the other way get none of that. No hibernation, no free keepalive, and they will go silent on you without even a close event to grieve over. I wish that asymmetry were louder in the docs than it is. On the giveaway side, the happier surprise was that making the draw checkable, instead of asking people to trust me, came out to less code than a hand-rolled random draw would have. That streamer has no clue any of this happened, which is probably how it should be. But I won't pretend it isn't satisfying to watch Dibs tick over an entry forty minutes into a stream and not flinch, right at the spot where I'd watched the other thing fall over.
作者经验D1 +1

把博客迁到 D1 免费层上的 EmDash:读取行额度用尽,作者两次回滚

tozai(@ccietry_tozai)尝试把日均约 800 独立访客、7,000 次浏览的 WordPress 博客迁到 Cloudflare 上的 EmDash,结果超出 D1 免费层每天 500 万读取行的额度;他没有改用没有硬上限的付费方案,而是退回 WordPress。

D1

Yan Zhu(picturepan2):D1 行读取暴增带来约 $400 账单。

Yan Zhu(@picturepan2)称上个月也遭遇约 $400 的 Cloudflare 账单,因 D1 数据库 row reads 爆量。他引用 shmily7 的 Durable Objects 串文作对照,未报告退款或支持结果。

社区资源Workers +3

Cloudflare Recognizer 用量告警工具

一个开源的 Workers + KV 工具:按 Cron 轮询 Cloudflare 分析数据,按自定义账期对照合同承诺量跟踪用量,并在突增时告警,主要面向 Enterprise 账号。