Outgoing WebSockets don’t hibernate
Fabrizio Lamanna (@fabriziolams) describes building a Twitch giveaway tool on Cloudflare and learning that Durable Objects hibernate incoming WebSockets but not outgoing ones, so an always-on outbound chat connection keeps the object resident and on the bill; he moved that connection to Containers.
- Products
- Durable ObjectsContainersWorkers
- Source
- Author experience𝕏 article
- Author
- Fabrizio Lamanna (@fabriziolams)
Key points
- His tool has to stay connected to chat for the whole giveaway, from ninety seconds to nearly an hour. A Worker cannot hold that connection, and while a Durable Object can hold WebSockets and hibernate idle ones, hibernation only applies to connections coming in. The outbound socket to Twitch never qualifies, and anything keeping it awake, a held socket or a polling loop, keeps the whole object in memory and on the bill around the clock.
- He also hit an outbound connection inside a Durable Object that went silent after twenty to thirty minutes on real streams while still reporting open; that timing is from his own logs. He cites open workerd issues asking for hibernation of outgoing WebSockets and for keepalive pings.
- His design: the chat client runs as one long-lived Node process in a Container; a single Durable Object routes messages to one Durable Object per giveaway, which streams updates to the dashboard and overlay over hibernating incoming WebSockets. Containers are pooled by a hash of the channel name on lower tiers, and big channels get their own container.
- His lesson: match the job to the tool (Workers for request and response, Durable Objects for state and fan-out to browsers, Containers for always-on outbound connections), and check which way a WebSocket points, since incoming ones hibernate and cost nothing while idle and outgoing ones do not.
- Official docs (rechecked 2026-10-16): hibernation is only supported when a Durable Object acts as a WebSocket server, and outgoing WebSockets do not hibernate; outbound WebSockets and TCP sockets also prevent eviction, and idle objects that cannot hibernate keep incurring duration charges.
Original sources
Published
Original post
Read on 𝕏Fabrizio Lamanna (@fabriziolams) describes building a Twitch giveaway tool on Cloudflare and learning that Durable Objects hibernate incoming WebSockets but not outgoing ones, so an always-on outbound chat connection keeps the object resident and on the bill; he moved that connection to Containers.
Fabrizio Lamanna
@fabriziolams · Original 𝕏 post
https://t.co/cBtioX6O30
Read originalArchived
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.
More to read
Moving a blog to EmDash on the D1 free tier: row reads ran out and the author rolled back twice
tozai (@ccietry_tozai) tried moving a WordPress blog with about 800 daily unique visitors and 7,000 page views to EmDash on Cloudflare, exceeded D1’s 5 million daily rows-read free limit, and went back to WordPress rather than move to a paid plan without a hard cap.
Yan Zhu (picturepan2): about $400 from a D1 row-reads spike.
Yan Zhu (@picturepan2) says he also hit an about $400 Cloudflare bill the previous month when D1 database row reads spiked. He quotes the shmily7 Durable Objects thread for context and does not report a refund or support outcome.
Cloudflare Recognizer
An open-source Workers + KV tool that polls Cloudflare analytics on a cron, tracks usage against a contract commit on a custom billing cycle, and alerts on surges, aimed at Enterprise accounts.