{"slug":"durable-channels-built-in","url":"https://tako.sh/blog/durable-channels-built-in/","canonical":"https://tako.sh/blog/durable-channels-built-in/","title":"Durable Channels, Built In","date":"2026-04-13T01:46","description":"Tako now ships durable WebSocket and SSE channels with replay, reconnection, and per-channel auth — no Pusher, no Redis, no sidecars.","author":null,"image":"753b1404dea2","imageAlt":null,"headings":[{"depth":2,"slug":"how-it-works","text":"How it works"},{"depth":2,"slug":"the-durable-part","text":"The durable part"},{"depth":2,"slug":"why-we-built-it-in-not-bolted-on","text":"Why we built it in, not bolted on"}],"markdown":"Most apps need real-time eventually. A chat pane, a live dashboard, a collaborative cursor, a webhook fanning out to connected clients. The path there is depressingly familiar: stand up a Pusher account, or glue together Redis pub/sub and a WebSocket gateway, or pay Ably per connection. One more service, one more bill, one more thing to keep alive.\n\nTako now ships this as a built-in primitive. Two new lines in your app give you a durable, authenticated pub/sub channel on your own server, with SSE and WebSocket transports, replay across reconnects, and per-channel auth — served directly by the Tako proxy.\n\n## How it works\n\nA channel is just a named stream. Your app defines it and declares who can read or write. The Tako proxy owns the public endpoint at `/channels/<name>`, handles the SSE or WebSocket handshake, persists messages to a small SQLite store on the server, and asks your app for an auth decision on every connection.\n\n```go\ntako.Channels.Register(\"chat\", tako.ChannelDefinition{\n  Transport: tako.ChannelTransportWS,\n  ParamsSchema: []byte(`{\n    \"type\": \"object\",\n    \"properties\": { \"roomId\": { \"type\": \"string\" } },\n    \"required\": [\"roomId\"]\n  }`),\n  Auth: &tako.ChannelAuthScheme{HeaderName: \"authorization\"},\n  Verify: func(input tako.VerifyInput) tako.ChannelAuthDecision {\n    userID := authenticate(input.Header)\n    if userID == \"\" {\n      return tako.RejectChannel()\n    }\n    return tako.AllowChannel(tako.ChannelGrant{Subject: userID})\n  },\n})\n```\n\nThe filename or registered name is the channel name; typed params travel as query parameters, so clients connect to paths like `/channels/chat?roomId=lobby`. The verify callback runs inside your app, so it can touch your session store, your database, your feature flags — whatever \"is this user allowed\" already means in your code. The same callback works in the [JavaScript SDK](/docs).\n\n```d2\ndirection: right\n\nclient: Client {style.fill: \"#9BC4B6\"; style.font-size: 16}\nproxy: Tako Proxy {style.fill: \"#E88783\"; style.font-size: 16}\napp: Your App {style.fill: \"#FFF9F4\"; style.stroke: \"#2F2A44\"; style.font-size: 16}\nstore: SQLite replay {style.fill: \"#E88783\"; style.font-size: 16}\n\nclient -> proxy: \"GET /channels/chat?roomId=lobby\"\nproxy -> app: \"POST /channels/authorize\"\napp -> proxy: \"ok + subject\"\nproxy -> store: \"replay from Last-Event-ID\"\nproxy -> client: \"SSE / WebSocket stream\"\n```\n\n## The durable part\n\n\"Durable\" is the word we chose carefully. Messages published to a channel land in a bounded replay window — 24 hours by default, tunable per channel. When a client reconnects with a `Last-Event-ID` header (SSE) or `last_message_id` query param (WebSocket), the proxy replays everything they missed, in order, and then seamlessly hands off to the live tail.\n\nThat means restarts don't drop messages. A bad wifi handoff on a phone doesn't drop messages. A [rolling deploy](/blog/what-happens-when-you-run-tako-deploy) of your app doesn't drop messages — the proxy keeps the stream, your app just gets asked to re-authorize the reconnection. And if a cursor is older than the replay window, the proxy returns `410 Gone` so the client knows to start fresh rather than silently skip events.\n\nEach channel has four knobs on its lifecycle:\n\n| Setting                   | Default  | What it controls                         |\n| ------------------------- | -------- | ---------------------------------------- |\n| `replayWindowMs`          | 24 hours | How far back reconnecting clients can go |\n| `inactivityTtlMs`         | off      | Drop channel state after idle period     |\n| `keepaliveIntervalMs`     | 25s      | SSE/WS heartbeat cadence                 |\n| `maxConnectionLifetimeMs` | 2 hours  | Cap on a single connection's lifetime    |\n\nYour auth callback can return per-user overrides, so a free-tier subscriber might get a 5-minute replay window while a paid customer gets the full 24 hours — same code path, same channel.\n\n## Why we built it in, not bolted on\n\nThe honest answer is: most deploy tools stop at \"get your code running.\" Kamal, Sidekick, Dokku, Coolify — they ship your container, point a proxy at it, and hand you the keys. Anything your app needs beyond HTTP — pub/sub, queues, scheduled work — is something you glue in yourself.\n\nWe think that's a weird place to stop. A proxy that already terminates TLS, tracks connected clients, and survives your app restarting is in the exact right position to own the durable channel too. It's less code in your app, one less service to run, and one less vendor on your invoice.\n\nChannels are the first piece. The [roadmap](/blog/build-your-own-edge-network-on-commodity-hardware) also includes durable queues, scheduled workflows, and image optimization — the primitives apps actually need, right where your app already lives. Try them today: `tako init`, add a channel, `tako dev`, and you have a real-time feature running locally over [real HTTPS](/blog/local-dev-with-real-https) in about a minute. See the [docs](/docs/how-tako-works) for the full protocol."}