# Vask full AI docs context Generated from public docs markdown. --- title: Documentation source: https://vask.dev/docs markdown: https://vask.dev/docs.md --- # Documentation Vask is a drop-in replacement for Pusher. Get started in minutes with your existing Pusher setup or explore our integration guides. ## Quick Start 1. **Create an account** - Sign up in the dashboard, import Pusher credentials, or let the agent skill register an app with your GitHub-published SSH key. 2. **Configure your application** - Use your Vask credentials with any Pusher SDK. 3. **Send messages** - Start broadcasting real-time events to your users. ## Integration Guides - [Setup with agent](/docs/agent) - Install `vask-realtime` from skills.sh or use a copy-paste prompt for any AI agent to wire Vask into a Pusher-compatible stack. - [Laravel](/docs/laravel) - Complete guide for Laravel Broadcasting with queues, channels, and events. - [Channels](/docs/channels) - Public, private, presence, cache, and encrypted channel behavior. - [Events](/docs/events) - Server events, client events, batch events, recipient exclusion, and subscription counts. - [Authentication and signatures](/docs/authentication) - Channel authorization, user authentication, and HTTP API signing. - [User authentication](/docs/user-authentication) - `pusher:signin`, `pusher:signin_success`, watchlists, and user-scoped features. - [WebSocket protocol](/docs/websocket-protocol) - `wss://wss.vask.dev/app/{app_key}` frames and close codes. - [HTTP API reference](/docs/http-api) - `https://api.vask.dev/apps/{app_key}/...` endpoints and request signing. - [Cache channels](/docs/cache-channels) - Last-event replay and cache miss handling. - [Encrypted channels](/docs/encrypted-channels) - End-to-end encrypted private channels. - [Watchlist events](/docs/watchlist-events) - Online/offline updates for signed-in users. - [User connections](/docs/user-connections) - Send events to users and terminate signed-in user connections. - [Subscription count](/docs/subscription-count) - Live count events, API info responses, and webhooks. - [Webhooks](/docs/webhooks) - Pusher-compatible webhooks for channel and presence events, with signing and retry details. - **JavaScript** - Pure JavaScript integration without frameworks. _Coming soon._ - **PHP** - Server-side PHP integration guide. _Coming soon._ ## Channels Channels are the fundamental way to organize and filter data streams. Vask supports three types of channels: - **Public channels** - Can be subscribed to by anyone who knows the channel name. - **Private channels** - Require authentication and are prefixed with `private-`. - **Presence channels** - Like private channels but also track who's online, prefixed with `presence-`. ## Events Events are messages sent through channels to connected clients. Each event has: - A **name** that identifies the type of message. - Optional **data** payload (JSON). - Automatic **metadata** like timestamps and socket IDs. Events can be triggered from your server or directly from authenticated clients (client events). ## Limits Vask accepts WebSocket message payloads up to 64KB per message. Messages larger than 64KB are rejected, so split larger data or send a reference to externally stored content. ## Authentication Private and presence channels require authentication to ensure only authorized users can access sensitive data: 1. Client requests to subscribe to a private/presence channel. 2. Your server receives an authentication request at your configured endpoint. 3. You verify the user's permissions and return a signed token. 4. Client uses the token to complete the subscription. Vask uses the same authentication mechanism as Pusher, making it fully compatible with existing auth endpoints. --- title: Setup with agent source: https://vask.dev/docs/agent markdown: https://vask.dev/docs/agent.md --- # Setup with agent Install the Vask realtime skill, then let your AI agent wire the right Pusher-compatible setup for your stack. This guide is intentionally framework-agnostic. Your agent should inspect the codebase first, then choose the Pusher-compatible server and client setup that fits the app already in front of it. ## Recommended: install the skill For JavaScript, TypeScript, Rails, Django, PHP, or other non-Laravel projects, install the public Vask skill: ```bash npx skills add vask-dev/skills --skill vask-realtime ``` Then ask your agent: ```text Use $vask-realtime to set up Vask realtime WebSockets in this app. ``` The skill includes the Vask agent signup flow. If your local SSH key is published on GitHub, the agent can register or recover a default Vask app by signing a short payload with your SSH key and calling `https://vask.dev/api/agent-signup`. No browser, OAuth prompt, password, or CAPTCHA is needed for that path. Laravel users should usually install [`vask/laravel`](/docs/laravel) instead; it has Laravel-native setup, diagnostics, and webhook helpers. ## Copy-paste fallback prompt Use this prompt if your agent does not support skills yet. Paste it into Claude, Codex, Cursor, Windsurf, or any agent that can edit your app directly. ```text # Integrate Vask into this app Vask is a low-latency Pusher-compatible websocket platform powered by Cloudflare. Implement or migrate to a complete Vask integration for this codebase using the stack and conventions already present in the project. Goal: - send server-side events through Vask - subscribe from the client - keep the implementation production-ready - use existing framework patterns instead of inventing a parallel setup Use these Vask values: - app key: YOUR_VASK_APP_KEY - key: YOUR_VASK_APP_KEY - secret: YOUR_VASK_SECRET - host: wss.vask.dev - port: 443 - scheme: https / wss - auth endpoint: YOUR_AUTH_ENDPOINT_IF_USING_PRIVATE_OR_PRESENCE_CHANNELS Requirements: - install the correct Pusher-compatible server and client libraries for this stack - configure the server broadcaster/client with the Vask credentials - add one end-to-end example event and one example channel subscription - if the app uses private or presence channels, wire auth with the app's existing auth system - map the credentials into whatever env vars or config files this stack already uses - use env/config files for secrets, not hardcoded values - explain which files changed and how to run the integration locally - ensure you use the Vask app_key anywhere a Pusher-compatible SDK asks for app id or key, and ensure you send a valid origin header Constraints: - choose the framework-specific implementation details yourself - do not assume Laravel or any specific framework unless this repo already uses it - do not change unrelated architecture - keep code minimal and idiomatic for this project If you don't have the details you need, ask me. ``` ## Replace these values first - **YOUR_VASK_APP_KEY** - your public Vask app key. Use this anywhere a Pusher-compatible SDK asks for app id or key. - **YOUR_VASK_SECRET** - your server secret. - **YOUR_VASK_HOST** - your Vask host, usually the app host you were given. - **443** and **https** - keep these unless Vask told you to use a different port or scheme. - **YOUR_AUTH_ENDPOINT_IF_USING_PRIVATE_OR_PRESENCE_CHANNELS** - only needed for protected channels. ## Need a manual guide? If you want a concrete framework example instead, use the [Laravel integration guide](/docs/laravel). --- title: Laravel Integration Guide source: https://vask.dev/docs/laravel markdown: https://vask.dev/docs/laravel.md --- # Laravel Integration Guide Vask is a drop-in Pusher replacement for Laravel Broadcasting and Laravel Echo. The fastest way in is the `vask/laravel` Composer package — it OAuths into your account, writes the `PUSHER_*` credentials to `.env`, and verifies the connection. ## Installation ### Recommended: the `vask/laravel` package ```bash composer require vask/laravel php artisan vask:install ``` What `vask:install` does: - Runs the OAuth device flow (you approve a short code in your browser — no git config or local tokens). - Writes `PUSHER_APP_ID`, `PUSHER_APP_KEY`, `PUSHER_APP_SECRET`, `PUSHER_HOST`, `PUSHER_PORT`, `PUSHER_SCHEME`, and `PUSHER_APP_CLUSTER` to your `.env`. - Runs `vask:doctor` afterwards to confirm everything connects. Source: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel) · [packagist.org/packages/vask/laravel](https://packagist.org/packages/vask/laravel). ### Manual install Prefer to wire it up by hand (CI, custom env management, audit)? Add your Vask app credentials to `.env`: ```env BROADCAST_CONNECTION=pusher PUSHER_APP_ID=your_app_key PUSHER_APP_KEY=your_app_key PUSHER_APP_SECRET=your_app_secret PUSHER_HOST=wss.vask.dev PUSHER_PORT=443 PUSHER_SCHEME=https PUSHER_APP_CLUSTER=vask ``` Then follow [Laravel's broadcasting docs](https://laravel.com/docs/broadcasting#pusher-channels) for the rest of the standard setup. ## `vask:doctor` After install (or any time you suspect drift), run the diagnostic: ```bash php artisan vask:doctor php artisan vask:doctor --no-ping --no-broadcast # skip live network checks ``` It validates your `PUSHER_*` config, optionally pings `wss.vask.dev`, and optionally fires a test broadcast end-to-end. Source: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel). ## Demo route `vask/laravel` ships a local-only page at `/_vask/demo`. Start your dev server, visit it, click an emoji, and watch the round-trip (Laravel → Vask → browser) — including latency — without writing any frontend code. It exercises both server-side broadcasts and Pusher client events. The route only registers when `app()->environment() === 'local'`. Disable it entirely with: ```env VASK_NO_DEMO=true ``` ## Broadcasting Events ### Creating a Broadcast Event Create an event that implements `ShouldBroadcast`: ```php message->room_id); } public function broadcastAs(): string { return 'message.sent'; } public function broadcastWith(): array { return [ 'id' => $this->message->id, 'content' => $this->message->content, 'user' => $this->userName, 'created_at' => $this->message->created_at->toISOString(), ]; } } ``` ### Triggering Events Broadcast events from your controllers or anywhere in your application: ```php use App\Events\MessageSent; // In a controller method public function sendMessage(Request $request) { $message = Message::create([ 'room_id' => $request->room_id, 'user_id' => auth()->id(), 'content' => $request->content, ]); // Broadcast the event broadcast(new MessageSent( $message, auth()->user()->name )); return response()->json($message); } // Or use the event() helper event(new MessageSent($message, $userName)); // For immediate broadcasting (bypass queue) broadcast(new MessageSent($message, $userName))->toOthers(); ``` ## Client-Side Setup ### Configure Laravel Echo Initialize Echo in your `bootstrap.js` or `app.{js,ts,tsx}` file: ```javascript import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; window.Pusher = Pusher; window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, cluster: 'vask', wsHost: 'wss.vask.dev', forceTLS: true, enabledTransports: ['ws'], }); ``` ### Listening to Events Subscribe to channels and listen for events in your components: ```javascript // Listen to a public channel Echo.channel('chat.1').listen('.message.sent', (event) => { console.log('New message:', event); // Update your UI with the new message addMessageToChat(event); }); // Listen to multiple events Echo.channel('notifications') .listen('.user.joined', (e) => { console.log(e.userName + ' joined'); }) .listen('.user.left', (e) => { console.log(e.userName + ' left'); }); // Leave a channel Echo.leave('chat.1'); ``` ## Private Channels ### Authorization Routes Define channel authorization logic in `routes/channels.php`: ```php rooms()->where('room_id', $roomId)->exists(); }); // Return data with authorization Broadcast::channel('user.{userId}', function (User $user, int $userId) { if ($user->id === $userId) { return [ 'id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url, ]; } return false; }); ``` ### Broadcasting to Private Channels ```php use Illuminate\Broadcasting\PrivateChannel; class OrderStatusUpdated implements ShouldBroadcast { public function broadcastOn(): Channel { return new PrivateChannel('user.' . $this->order->user_id); } } ``` ### Listening to Private Channels ```javascript // Subscribe to a private channel Echo.private('user.' + userId) .listen('.order.updated', (event) => { console.log('Order updated:', event.order); }) .listen('.payment.received', (event) => { showNotification('Payment received: $' + event.amount); }); ``` ## Presence Channels Presence channels let you track who's online in real-time. ### Authorization with User Data ```php Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) { if ($user->canJoinRoom($roomId)) { return [ 'id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url, ]; } }); ``` ### Using Presence Channels ```php use Illuminate\Broadcasting\PresenceChannel; class UserJoinedRoom implements ShouldBroadcast { public function broadcastOn(): Channel { return new PresenceChannel('room.' . $this->roomId); } } ``` ### Client-Side Presence ```javascript let onlineUsers = []; Echo.join('room.' + roomId) .here((users) => { // Initial users in the channel console.log('Users online:', users); onlineUsers = users; updateUsersList(users); }) .joining((user) => { // User joined the channel console.log(user.name + ' joined'); onlineUsers.push(user); addUserToList(user); }) .leaving((user) => { // User left the channel console.log(user.name + ' left'); onlineUsers = onlineUsers.filter((u) => u.id !== user.id); removeUserFromList(user); }) .error((error) => { console.error('Connection error:', error); }) .listen('.message.sent', (event) => { // Listen to events on presence channel addMessageToChat(event); }); ``` ## Webhooks Vask sends webhooks for channel, presence, and client events. The `vask/laravel` package gives you typed payloads and auto-registers `POST /webhooks/vask` the first time it sees a handler — no handler, no route. Register handlers in a service provider's `boot()`: ```php use Vask\Laravel\Facades\Vask; use Vask\Laravel\Webhooks\Payloads\ChannelOccupiedPayload; use Vask\Laravel\Webhooks\Payloads\ChannelVacatedPayload; use Vask\Laravel\Webhooks\Payloads\MemberAddedPayload; use Vask\Laravel\Webhooks\Payloads\MemberRemovedPayload; use Vask\Laravel\Webhooks\Payloads\ClientEventPayload; public function boot(): void { Vask::onChannelOccupied(fn (ChannelOccupiedPayload $event) => /* ... */); Vask::onChannelVacated(fn (ChannelVacatedPayload $event) => /* ... */); Vask::onMemberAdded([MemberHandler::class, 'joined']); Vask::onMemberRemoved([MemberHandler::class, 'left']); Vask::onClientEvent(LogClientEvent::class); // invokable class } ``` The route is registered outside the `web` middleware group, so CSRF doesn't apply. Customise the path or take over registration yourself: ```php Vask::webhookPath('/api/vask-hooks'); Vask::disableAutoWebhookRoute(); ``` Full reference: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel). ## Troubleshooting ### Run `vask:doctor` first ```bash php artisan vask:doctor ``` It catches missing env vars, bad host config, and broken broadcasts before you go hunting through logs. ### Connection Issues **Problem:** "Failed to connect to Pusher". **Solution:** Check your `.env` credentials and ensure `PUSHER_HOST` is set to `wss.vask.dev`. Re-run `php artisan vask:install` to rewrite them. ### Events Not Broadcasting **Problem:** Events are not being received by clients. **Solutions:** - Ensure your queue worker is running: `php artisan queue:work`. - Check that `BROADCAST_CONNECTION=pusher` in `.env`. - Verify the event implements `ShouldBroadcast`. - Check Laravel logs for broadcasting errors. ### Private Channel Authorization Failing **Problem:** 403 Forbidden when joining private channels. **Solutions:** - Verify authorization logic in `routes/channels.php`. - Ensure user is authenticated before subscribing. - Check that channel names match between backend and frontend. - Verify CSRF token is being sent with authorization requests. ### Debug Mode Enable Pusher debug mode to see detailed logs: ```javascript window.Echo = new Echo({ // ... other config enabledTransports: ['ws', 'wss'], // Enable debug mode enableLogging: true, }); // Also enable Pusher logging Pusher.logToConsole = true; ``` ## Next Steps - `vask/laravel` package on GitHub: https://github.com/vask-dev/laravel - `vask/laravel` on Packagist: https://packagist.org/packages/vask/laravel - [Laravel Broadcasting Docs](https://laravel.com/docs/broadcasting) - [Laravel Echo on GitHub](https://github.com/laravel/echo) --- title: Channels source: https://vask.dev/docs/channels markdown: https://vask.dev/docs/channels.md --- # Channels Channels are named streams. A client opens one WebSocket connection to Vask, then subscribes that connection to one or more channels. Use the same channel names and SDK calls you would use with Pusher Channels. Connect to `wss://wss.vask.dev/app/{app_key}` and use your Vask `app_key` as the SDK key. ## Channel types | Type | Prefix | Auth required | Tracks members | Notes | | --------------- | -------------------------- | ------------- | -------------- | ---------------------------------------------------------------- | | Public | none | No | No | Any connected client with the `app_key` can subscribe. | | Private | `private-` | Yes | No | Your app signs each subscription. | | Presence | `presence-` | Yes | Yes | Like private channels, plus member roster and join/leave events. | | Cache | `cache-` | No | No | Replays the last server-triggered event to new subscribers. | | Private cache | `private-cache-` | Yes | No | Private channel behavior plus cache replay. | | Presence cache | `presence-cache-` | Yes | Yes | Presence channel behavior plus cache replay. | | Encrypted | `private-encrypted-` | Yes | No | Private channel behavior plus SDK-managed encryption. | | Encrypted cache | `private-encrypted-cache-` | Yes | No | Encrypted channel behavior plus cache replay. | ## Public channels Public channels do not call your auth endpoint. Use them only for data that can be read by anyone who knows the channel name. ```js const pusher = new Pusher('app_key', { wsHost: 'wss.vask.dev', wsPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], }); const channel = pusher.subscribe('public-feed'); channel.bind('message.created', (event) => { console.log(event); }); ``` ## Private channels Private channels require a server-side authorization response. The SDK posts `socket_id` and `channel_name` to your auth endpoint, then sends the returned `auth` value in the `pusher:subscribe` frame. ```js const channel = pusher.subscribe('private-dashboard.42'); channel.bind('metric.updated', (event) => { console.log(event); }); ``` Your auth endpoint decides whether the current user can subscribe. ```json { "auth": "app_key:hmac_signature" } ``` ## Presence channels Presence channels use the same authorization flow as private channels, but the response also includes JSON-encoded `channel_data`. ```json { "auth": "app_key:hmac_signature", "channel_data": "{\"user_id\":\"user-123\",\"user_info\":{\"name\":\"Ada\"}}" } ``` The `user_id` is the identity Vask uses for the roster. If the same user has several tabs open, they count as one presence member. ```js const room = pusher.subscribe('presence-room.42'); room.bind('pusher:subscription_succeeded', (members) => { console.log(members); }); room.bind('pusher:member_added', (member) => { console.log(member.id); }); room.bind('pusher:member_removed', (member) => { console.log(member.id); }); ``` ## Naming rules Channel names should be stable ASCII strings. Keep names short and application scoped. ```text public-feed private-user.123 presence-document.456 cache-market-data.BTCUSD private-encrypted-chat.789 ``` Do not put secrets in channel names. Private and presence channels enforce access through the auth signature, not through obscurity. ## Related docs - [Authentication and signatures](/docs/authentication) - [Cache channels](/docs/cache-channels) - [Encrypted channels](/docs/encrypted-channels) - [HTTP API reference](/docs/http-api) --- title: Events source: https://vask.dev/docs/events markdown: https://vask.dev/docs/events.md --- # Events Events are named payloads delivered to subscribed clients. Vask supports Pusher-compatible server events, batch events, client events, server-to-user events, and system events. Use `https://api.vask.dev` for HTTP API requests and `wss://wss.vask.dev` for WebSocket clients. ## Server events Your backend triggers events with the HTTP API or a Pusher-compatible server SDK. The public endpoint shape is: ```text POST https://api.vask.dev/apps/{app_key}/events ``` Example JSON body: ```json { "name": "order.updated", "channel": "private-orders.123", "data": "{\"status\":\"paid\"}" } ``` For multi-channel fanout, use `channels` instead of `channel`. ```json { "name": "announcement.created", "channels": ["public-feed", "private-team.42"], "data": "{\"title\":\"Deploy complete\"}" } ``` ## Batch events Use `batch_events` to send multiple channel events in one request. ```text POST https://api.vask.dev/apps/{app_key}/batch_events ``` ```json { "batch": [ { "name": "metric.updated", "channel": "private-dashboard.1", "data": "{\"value\":42}" }, { "name": "metric.updated", "channel": "private-dashboard.2", "data": "{\"value\":99}" } ] } ``` ## Excluding the sender Pass `socket_id` to avoid echoing an event back to the socket that initiated the action. ```json { "name": "cursor.moved", "channel": "presence-document.42", "data": "{\"x\":120,\"y\":80}", "socket_id": "1234.5678" } ``` ## Client events Client events must start with `client-`. They are accepted only on private and presence channels, and only when client events are enabled for the app. ```js const channel = pusher.subscribe('private-room.42'); channel.trigger('client-typing', { is_typing: true, }); ``` Client events are not supported on public channels or encrypted channels. ## Server-to-user events When a client signs in with [user authentication](/docs/user-authentication), your backend can send an event to that `user_id` without requiring a channel subscription. ```text POST https://api.vask.dev/apps/{app_key}/users/{user_id}/events ``` ```json { "name": "notification.created", "data": "{\"body\":\"Your export is ready\"}" } ``` ## System events Vask emits Pusher-compatible system events: | Event | Direction | Purpose | | ---------------------------------------- | -------------- | ---------------------------------------- | | `pusher:connection_established` | Vask to client | Initial socket id and heartbeat timeout. | | `pusher:pong` | Vask to client | Response to `pusher:ping`. | | `pusher:signin_success` | Vask to client | User sign-in completed. | | `pusher:error` | Vask to client | Command or auth failure. | | `pusher_internal:subscription_succeeded` | Vask to client | Channel subscription accepted. | | `pusher_internal:member_added` | Vask to client | Presence member joined. | | `pusher_internal:member_removed` | Vask to client | Presence member left. | | `pusher_internal:subscription_count` | Vask to client | Subscription count changed. | Most SDKs expose these through callbacks instead of requiring direct frame handling. ## Info responses The `events` and `batch_events` endpoints can return channel counts when `info` is requested. ```json { "name": "order.updated", "channel": "private-orders.123", "data": "{}", "info": "subscription_count" } ``` Counts are snapshots taken independently from delivery. ## Related docs - [HTTP API reference](/docs/http-api) - [Subscription count](/docs/subscription-count) - [User connections](/docs/user-connections) --- title: Authentication and Signatures source: https://vask.dev/docs/authentication markdown: https://vask.dev/docs/authentication.md --- # Authentication and Signatures Vask uses Pusher-compatible signing for channel authorization, user authentication, webhooks, and HTTP API requests. Use your Vask `app_key` anywhere a Pusher SDK asks for an app key. If a server SDK also asks for `app_id`, use the same `app_key`. ## Channel authorization Private, presence, private cache, presence cache, encrypted, and encrypted cache channels require authorization. The client SDK sends your application server: ```text socket_id=1234.5678 channel_name=private-dashboard.42 ``` For private channels, sign this string with HMAC-SHA256 using the app secret: ```text 1234.5678:private-dashboard.42 ``` Return: ```json { "auth": "app_key:hmac_signature" } ``` ## Presence authorization Presence channels add `channel_data`. The exact JSON string you return must be the exact JSON string you sign. ```json { "user_id": "user-123", "user_info": { "name": "Ada" } } ``` Sign: ```text 1234.5678:presence-room.42:{"user_id":"user-123","user_info":{"name":"Ada"}} ``` Return: ```json { "auth": "app_key:hmac_signature", "channel_data": "{\"user_id\":\"user-123\",\"user_info\":{\"name\":\"Ada\"}}" } ``` ## User authentication User authentication identifies the connection itself. It powers server-to-user events, watchlist events, and user connection termination. The auth endpoint signs: ```text 1234.5678::user::{"id":"user-123","name":"Ada"} ``` Return: ```json { "auth": "app_key:hmac_signature", "user_data": "{\"id\":\"user-123\",\"name\":\"Ada\"}" } ``` The client then sends `pusher:signin` over the WebSocket connection. ## HTTP API authentication Every request to `https://api.vask.dev/apps/{app_key}/...` must be signed. Required query parameters: | Parameter | Description | | ---------------- | ------------------------------------------------------------- | | `auth_key` | Your Vask `app_key`. | | `auth_timestamp` | Unix timestamp in seconds. | | `auth_version` | Use `1.0`. | | `body_md5` | MD5 hex digest of the request body for non-empty POST bodies. | | `auth_signature` | HMAC-SHA256 hex digest of the string to sign. | String to sign: ```text METHOD /apps/{app_key}/events auth_key=app_key&auth_timestamp=1715520000&auth_version=1.0&body_md5=... ``` Node example: ```js import crypto from 'node:crypto'; function signRequest({ method, path, params, secret }) { const query = Object.keys(params) .sort() .map((key) => `${key}=${params[key]}`) .join('&'); return crypto .createHmac('sha256', secret) .update(`${method.toUpperCase()}\n${path}\n${query}`) .digest('hex'); } ``` ## Webhook signatures Webhook deliveries use the app secret to sign the raw request body. ```text X-Pusher-Key: app_key X-Pusher-Signature: hmac_sha256(raw_body, app_secret) ``` Do not parse and re-encode the body before verification. ## Common failures | Symptom | Check | | ----------------------------------- | -------------------------------------------------------------------------------------------- | | `401` from HTTP API | Wrong secret, stale timestamp, missing `body_md5`, or signed path differs from request path. | | `pusher:error` on private subscribe | Auth endpoint signed the wrong `socket_id` or `channel_name`. | | Presence user missing | `channel_data` was not included, not valid JSON, or did not include `user_id`. | | User termination does nothing | The target connection has not completed `pusher:signin`. | ## Related docs - [User authentication](/docs/user-authentication) - [HTTP API reference](/docs/http-api) - [Webhooks](/docs/webhooks) --- title: User Authentication source: https://vask.dev/docs/user-authentication markdown: https://vask.dev/docs/user-authentication.md --- # User Authentication User authentication signs in a WebSocket connection as an application user. It is separate from private or presence channel authorization. Call `pusher.signin()` after connecting. A successful sign-in enables: - Server-to-user events. - Watchlist online/offline events. - Terminating all active connections for a `user_id`. ## Client setup ```js const pusher = new Pusher('app_key', { wsHost: 'wss.vask.dev', wsPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], userAuthentication: { endpoint: '/pusher/user-auth', }, }); pusher.signin(); ``` On sign-in, the SDK calls your `userAuthentication.endpoint` with the connection `socket_id`. ## Server response Your endpoint returns `auth` and `user_data`. The `user_data` value must be a JSON-encoded string and must include a non-empty `id`. ```json { "auth": "app_key:hmac_signature", "user_data": "{\"id\":\"user-123\",\"name\":\"Ada\"}" } ``` The signature covers: ```text {socket_id}::user::{user_data} ``` ## WebSocket frame The SDK sends this frame after it receives your auth response: ```json { "event": "pusher:signin", "data": { "auth": "app_key:hmac_signature", "user_data": "{\"id\":\"user-123\",\"name\":\"Ada\"}" } } ``` Vask replies with: ```json { "event": "pusher:signin_success", "data": { "user_data": "{\"id\":\"user-123\",\"name\":\"Ada\"}" } } ``` If the signature is invalid, the client receives `pusher:error`. ## Adding a watchlist Add `watchlist` to `user_data` when the signed-in user should receive online/offline events for a set of other users. ```json { "id": "user-123", "name": "Ada", "watchlist": ["user-456", "user-789"] } ``` Bind watchlist events after sign-in. ```js pusher.signin(); pusher.user.watchlist.bind('online', (event) => { console.log(event.user_ids); }); pusher.user.watchlist.bind('offline', (event) => { console.log(event.user_ids); }); ``` ## Presence is not user sign-in Presence channel `user_id` identifies a member inside that channel. User authentication identifies the whole connection. Use `pusher:signin` if you need server-to-user events or user termination. A client that only joins a presence channel is not enough for those features. ## Related docs - [Watchlist events](/docs/watchlist-events) - [User connections](/docs/user-connections) - [Authentication and signatures](/docs/authentication) --- title: WebSocket Protocol source: https://vask.dev/docs/websocket-protocol markdown: https://vask.dev/docs/websocket-protocol.md --- # WebSocket Protocol Vask speaks the Pusher Channels WebSocket protocol over native WebSockets. ```text wss://wss.vask.dev/app/{app_key}?protocol=7&client=js&version=8.4.0 ``` Fallback transports such as SockJS, HTTP streaming, and long polling are intentionally not part of Vask's WebSocket parity scope. ## Connection established After the WebSocket handshake, Vask sends: ```json { "event": "pusher:connection_established", "data": "{\"socket_id\":\"1234.5678\",\"activity_timeout\":120}" } ``` Use `socket_id` when signing private, presence, encrypted, and user authentication requests. ## Heartbeats Clients may send: ```json { "event": "pusher:ping", "data": {} } ``` Vask replies: ```json { "event": "pusher:pong", "data": {} } ``` SDKs usually handle this automatically. ## Subscribe Public channel: ```json { "event": "pusher:subscribe", "data": { "channel": "public-feed" } } ``` Private channel: ```json { "event": "pusher:subscribe", "data": { "channel": "private-dashboard.42", "auth": "app_key:hmac_signature" } } ``` Presence channel: ```json { "event": "pusher:subscribe", "data": { "channel": "presence-room.42", "auth": "app_key:hmac_signature", "channel_data": "{\"user_id\":\"user-123\",\"user_info\":{\"name\":\"Ada\"}}" } } ``` Successful subscriptions emit `pusher_internal:subscription_succeeded`. ## Unsubscribe ```json { "event": "pusher:unsubscribe", "data": { "channel": "presence-room.42" } } ``` Presence channels emit member removal events when the last connection for a `user_id` leaves. ## User sign-in ```json { "event": "pusher:signin", "data": { "auth": "app_key:hmac_signature", "user_data": "{\"id\":\"user-123\",\"name\":\"Ada\"}" } } ``` Vask replies with `pusher:signin_success` or `pusher:error`. ## Client events Client events must start with `client-` and include a subscribed private or presence channel. ```json { "event": "client-typing", "channel": "private-room.42", "data": "{\"is_typing\":true}" } ``` ## Close codes | Code | Meaning | | ------ | -------------------------------------------- | | `4001` | Unknown app. | | `4003` | App disabled. | | `4004` | Connection quota exceeded. | | `4005` | Unknown WebSocket path. | | `4009` | Unauthorized connection or command. | | `4200` | Signed-in user connection terminated by API. | | `4201` | Heartbeat timeout. | ## Unsupported transport scope Vask is a WebSocket service. Configure browser SDKs to use `ws` and `wss` transports. ```js const pusher = new Pusher('app_key', { wsHost: 'wss.vask.dev', wsPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], }); ``` ## Related docs - [Channels](/docs/channels) - [Authentication and signatures](/docs/authentication) - [User authentication](/docs/user-authentication) --- title: HTTP API Reference source: https://vask.dev/docs/http-api markdown: https://vask.dev/docs/http-api.md --- # HTTP API Reference Vask exposes a Pusher-compatible HTTP API at: ```text https://api.vask.dev ``` Use your Vask `app_key` in the path and in `auth_key`. ```text https://api.vask.dev/apps/{app_key}/events ``` If a Pusher-compatible SDK asks for `app_id`, set it to the same `app_key`. ## Authentication All HTTP API requests are signed with the app secret. See [Authentication and signatures](/docs/authentication) for the full signing rules. Required query parameters: ```text auth_key=app_key auth_timestamp=1715520000 auth_version=1.0 body_md5=md5_hex_for_non_empty_post_body auth_signature=hmac_sha256_signature ``` ## POST event ```text POST /apps/{app_key}/events ``` Triggers one event on one or more channels. ```json { "name": "message.created", "channel": "private-room.42", "data": "{\"body\":\"hello\"}" } ``` Multi-channel trigger: ```json { "name": "deploy.finished", "channels": ["public-feed", "private-team.42"], "data": "{\"version\":\"2026.05.22\"}" } ``` Optional fields: | Field | Description | | ----------- | --------------------------------------------------------------------------------- | | `socket_id` | Exclude a single connection from receiving the event. | | `info` | Comma-separated response attributes such as `subscription_count` or `user_count`. | Successful response: ```json {} ``` ## POST batch events ```text POST /apps/{app_key}/batch_events ``` Triggers multiple channel events. ```json { "batch": [ { "name": "message.created", "channel": "private-room.1", "data": "{\"body\":\"hello\"}" }, { "name": "message.created", "channel": "private-room.2", "data": "{\"body\":\"hello\"}" } ] } ``` Successful response: ```json {} ``` ## GET channels ```text GET /apps/{app_key}/channels ``` Returns occupied channels. Use `filter_by_prefix` to narrow the result. ```text GET /apps/{app_key}/channels?filter_by_prefix=presence-&info=user_count ``` Successful response: ```json { "channels": { "presence-room.1": { "user_count": 3 } } } ``` ## GET channel ```text GET /apps/{app_key}/channels/{channel_name} ``` Returns channel state and requested attributes. ```text GET /apps/{app_key}/channels/private-room.42?info=subscription_count ``` Successful response: ```json { "occupied": true, "subscription_count": 12 } ``` Cache channel response with `info=cache`: ```json { "occupied": true, "cache": { "event": "location.updated", "data": "{\"lat\":51.5,\"lng\":-0.12}", "ttl": 60 } } ``` ## GET presence users ```text GET /apps/{app_key}/channels/{channel_name}/users ``` This endpoint applies to presence channels only. ```json { "users": [{ "id": "user-123" }, { "id": "user-456" }] } ``` ## POST user event ```text POST /apps/{app_key}/users/{user_id}/events ``` Sends an event to all active connections that completed `pusher:signin` as that `user_id`. ```json { "name": "notification.created", "data": "{\"body\":\"Export ready\"}" } ``` Successful response: ```json {} ``` ## POST terminate user connections ```text POST /apps/{app_key}/users/{user_id}/terminate_connections ``` Closes all active connections that completed `pusher:signin` as that `user_id`. ```json {} ``` Successful response: ```json {} ``` ## Status codes | Status | Meaning | | ------ | ------------------------------------------------------------------------------ | | `200` | Request accepted. | | `400` | Invalid request body, parameters, channel name, or unsupported info attribute. | | `401` | Authentication failed. | | `403` | App disabled or quota restriction. | | `404` | App, channel, or route not found. | | `413` | Payload too large. | | `429` | Quota limit exceeded. | ## Related docs - [Events](/docs/events) - [User connections](/docs/user-connections) - [Authentication and signatures](/docs/authentication) --- title: Cache Channels source: https://vask.dev/docs/cache-channels markdown: https://vask.dev/docs/cache-channels.md --- # Cache Channels Cache channels remember the latest server-triggered event and send it to new subscribers before future live events. Use cache channels for "current state" surfaces such as locations, prices, counters, dashboards, and document metadata. ## Channel names | Channel behavior | Prefix | | ---------------- | -------------------------- | | Public cache | `cache-` | | Private cache | `private-cache-` | | Presence cache | `presence-cache-` | | Encrypted cache | `private-encrypted-cache-` | ## Subscribe ```js const channel = pusher.subscribe('cache-location.42'); channel.bind('location.updated', (event) => { console.log(event); }); channel.bind('pusher:cache_miss', () => { console.log('No cached event exists yet.'); }); ``` Private, presence, and encrypted cache channels use the same auth flow as their non-cache equivalents. ## Populating the cache Only server-triggered events populate cache channels. ```text POST https://api.vask.dev/apps/{app_key}/events ``` ```json { "name": "location.updated", "channel": "cache-location.42", "data": "{\"lat\":51.5,\"lng\":-0.12}" } ``` The next subscriber to `cache-location.42` receives that event after subscription succeeds. ## Cache miss handling When a cache channel has no cached value, Vask can notify both client and server: - Client: `pusher:cache_miss` - Webhook: `cache_miss` The usual pattern is: 1. Client subscribes to a cache channel. 2. Vask has no cached event. 3. Your backend receives a `cache_miss` webhook or your client reacts to `pusher:cache_miss`. 4. Your backend triggers a fresh event to the cache channel. ## Querying cache state Use the HTTP API with `info=cache`. ```text GET https://api.vask.dev/apps/{app_key}/channels/cache-location.42?info=cache ``` ```json { "occupied": true, "cache": { "event": "location.updated", "data": "{\"lat\":51.5,\"lng\":-0.12}", "ttl": 60 } } ``` If no cached value exists, `cache` is `null`. ## Related docs - [Channels](/docs/channels) - [HTTP API reference](/docs/http-api) - [Webhooks](/docs/webhooks) --- title: Encrypted Channels source: https://vask.dev/docs/encrypted-channels markdown: https://vask.dev/docs/encrypted-channels.md --- # Encrypted Channels Encrypted channels are private channels where compatible SDKs encrypt event payloads before they leave your server and decrypt them only inside authorized clients. Use encrypted channels for sensitive realtime payloads that should not be readable by Vask infrastructure. ## Channel names Encrypted channel names start with: ```text private-encrypted- ``` Encrypted cache channel names start with: ```text private-encrypted-cache- ``` ## Server SDK setup Use a 32-byte encryption master key encoded as base64. ```bash openssl rand -base64 32 ``` Node example: ```js import Pusher from 'pusher'; const pusher = new Pusher({ appId: 'app_key', key: 'app_key', secret: 'app_secret', host: 'api.vask.dev', useTLS: true, encryptionMasterKeyBase64: process.env.PUSHER_ENCRYPTION_KEY, }); ``` ## Client setup ```js const pusher = new Pusher('app_key', { wsHost: 'wss.vask.dev', wsPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], channelAuthorization: { endpoint: '/broadcasting/auth', }, }); const channel = pusher.subscribe('private-encrypted-chat.42'); ``` ## Authorization Encrypted subscriptions use the private-channel auth flow. Compatible SDKs add the encrypted-channel shared secret to the auth response. ```json { "auth": "app_key:hmac_signature", "shared_secret": "base64_shared_secret" } ``` Most application code should let the server SDK generate this response. ## Publishing Trigger one encrypted channel at a time. ```js await pusher.trigger('private-encrypted-chat.42', 'message.created', { body: 'hello', }); ``` Encrypted channels do not support client events. ## Cache variant Use `private-encrypted-cache-` when the latest encrypted event should be replayed to new subscribers. ```js const channel = pusher.subscribe('private-encrypted-cache-chat.42'); ``` ## Related docs - [Authentication and signatures](/docs/authentication) - [Cache channels](/docs/cache-channels) - [HTTP API reference](/docs/http-api) --- title: Watchlist Events source: https://vask.dev/docs/watchlist-events markdown: https://vask.dev/docs/watchlist-events.md --- # Watchlist Events Watchlist events tell a signed-in user when selected other users come online or go offline. Watchlists are part of [user authentication](/docs/user-authentication). A user is online when at least one active connection has completed `pusher:signin` for that `user_id`. ## Provide a watchlist Include `watchlist` in the signed `user_data` returned by your user auth endpoint. ```json { "id": "user-123", "name": "Ada", "watchlist": ["user-456", "user-789"] } ``` The exact JSON string must be signed as part of user authentication. ## Sign in and bind ```js pusher.signin(); pusher.user.watchlist.bind('online', (event) => { for (const userId of event.user_ids) { console.log(`${userId} online`); } }); pusher.user.watchlist.bind('offline', (event) => { for (const userId of event.user_ids) { console.log(`${userId} offline`); } }); ``` ## Event shape SDKs expose the events as `online` and `offline`. The underlying payload contains user ids. ```json { "name": "online", "user_ids": ["user-456"] } ``` ## Behavior - A watched user is online when one or more of their connections are signed in. - They become offline after their final signed-in connection closes. - Multiple tabs for the same `user_id` still produce one online state. - Presence-only users do not count unless they also completed `pusher:signin`. ## Related docs - [User authentication](/docs/user-authentication) - [User connections](/docs/user-connections) - [WebSocket protocol](/docs/websocket-protocol) --- title: User Connections source: https://vask.dev/docs/user-connections markdown: https://vask.dev/docs/user-connections.md --- # User Connections User connection APIs operate on connections that completed `pusher:signin`. Use these APIs when your backend needs to target or remove a user by `user_id`, regardless of which channels they joined. ## Send events to a user ```text POST https://api.vask.dev/apps/{app_key}/users/{user_id}/events ``` ```json { "name": "notification.created", "data": "{\"body\":\"Your report is ready\"}" } ``` The event is delivered to every active connection signed in as `{user_id}`. Client binding: ```js pusher.signin(); pusher.user.bind('notification.created', (event) => { console.log(event.body); }); ``` ## Terminate user connections ```text POST https://api.vask.dev/apps/{app_key}/users/{user_id}/terminate_connections ``` ```json {} ``` Vask closes all active sockets signed in as `{user_id}` with close code `4200`. ## Banning a user Termination is immediate, but it does not permanently block reconnects. To ban a user: 1. Update your application so future user auth requests for that user fail. 2. Call `terminate_connections` for the same `user_id`. If step 1 is skipped, the SDK can reconnect and sign in again. ## Presence is not enough This API only affects connections that completed user authentication. A connection that joined a presence channel but never sent `pusher:signin` is not terminated by user id. ## Related docs - [User authentication](/docs/user-authentication) - [HTTP API reference](/docs/http-api) - [Watchlist events](/docs/watchlist-events) --- title: Subscription Count source: https://vask.dev/docs/subscription-count markdown: https://vask.dev/docs/subscription-count.md --- # Subscription Count Subscription count reports how many active connections are subscribed to a channel. Vask supports subscription count in three places: - Live `pusher_internal:subscription_count` WebSocket events. - `info=subscription_count` on HTTP API responses. - `subscription_count` webhooks. Presence channels use `user_count` for distinct members and do not emit subscription-count events. ## Live client events When enabled for the app, Vask sends subscription-count events to subscribers of public, private, cache, and encrypted cache channels. ```json { "event": "pusher_internal:subscription_count", "channel": "public-feed", "data": "{\"subscription_count\":12}" } ``` SDKs may expose this as a channel event. ```js channel.bind('pusher:subscription_count', (event) => { console.log(event.subscription_count); }); ``` ## HTTP API info Request `subscription_count` for a single channel: ```text GET https://api.vask.dev/apps/{app_key}/channels/public-feed?info=subscription_count ``` ```json { "occupied": true, "subscription_count": 12 } ``` Request count information while triggering an event: ```json { "name": "feed.updated", "channel": "public-feed", "data": "{}", "info": "subscription_count" } ``` ## Webhooks Enable `subscription_count` on the app webhook endpoint to receive count changes server-side. ```json { "time_ms": 1736937600000, "events": [ { "name": "subscription_count", "channel": "public-feed", "subscription_count": 12 } ] } ``` Subscription-count webhooks can be frequent on busy channels. Enable them only when your backend needs every count change. ## Related docs - [Events](/docs/events) - [HTTP API reference](/docs/http-api) - [Webhooks](/docs/webhooks) --- title: Webhooks source: https://vask.dev/docs/webhooks markdown: https://vask.dev/docs/webhooks.md --- # Webhooks Vask sends webhooks so your backend can react to realtime activity in your app: subscriptions starting and ending, cache misses, subscription count changes, presence members joining and leaving, and client-published events on private and presence channels. The webhook body and signing scheme are Pusher-compatible, so existing Pusher webhook integrations work with Vask with no changes beyond your endpoint URL and credentials. ## Dashboard setup 1. Open your app in the Vask dashboard. 2. Choose the **Webhooks** tab. 3. Add a single endpoint URL for the app. One endpoint per app is supported on every plan. 4. New endpoints enable high-signal event types by default. `subscription_count` is supported but opt-in because busy channels can generate frequent deliveries. 5. Use the **Send test** button to deliver a synthetic event and confirm your endpoint accepts the payload and verifies the signature. You can update the URL, toggle events, disable, or delete the endpoint at any time. Deletes take effect immediately and stop further deliveries. ## Events The following event types are supported. All are enabled by default on new endpoints except `subscription_count`, which is opt-in: - `channel_occupied` — a channel transitions from zero to one or more subscribers. - `channel_vacated` — the last subscriber leaves a channel. - `cache_miss` — a client subscribes to a cache channel with no cached value. - `member_added` — a member joins a presence channel. - `member_removed` — a member leaves a presence channel. - `client_event` — a connected client publishes a client event. - `subscription_count` — a channel's subscription count changes. `client_event` webhooks fire only for **private** and **presence** channels, where client events are permitted. They are never sent for public channels, because public channels do not support client events. `subscription_count` events include the current subscriber count in their `subscription_count` field. Enable them only when you need per-channel count changes delivered to your server. ## Payload shape Vask delivers a JSON body with a millisecond timestamp and an array of events. The shape mirrors Pusher's webhook format. ```json { "time_ms": 1736937600000, "events": [ { "name": "channel_occupied", "channel": "presence-room.42" }, { "name": "member_added", "channel": "presence-room.42", "user_id": "user-123" }, { "name": "cache_miss", "channel": "cache-room.42" }, { "name": "client_event", "channel": "private-room.42", "event": "client-typing", "data": "{\"is_typing\":true}", "socket_id": "12345.6789", "user_id": "user-123" }, { "name": "subscription_count", "channel": "public-room.42", "subscription_count": 12 } ] } ``` Notes: - `time_ms` is the time Vask emitted the batch, in Unix milliseconds. - `events` is always an array. Endpoints should iterate it; do not assume a single event per request. - `data` on `client_event` is a JSON-encoded string, matching Pusher behavior. Decode it before use. - `user_id` is present on presence-channel events and on `client_event` payloads originating from authenticated presence clients. - `subscription_count` is present on `subscription_count` events and contains the current subscriber count for the channel. ## Signing headers Each delivery includes two headers your endpoint should verify before trusting the body: - `X-Pusher-Key` — the app key the request is associated with. - `X-Pusher-Signature` — a lowercase hex `HMAC-SHA256` of the **raw request body**, using your app secret as the HMAC key. Verify by recomputing the HMAC over the exact bytes you received and comparing in constant time. Do not re-serialize the JSON before verifying — the signature is over the raw body. The app secret never leaves Vask in plain text after creation. Store it as an environment variable in your backend, alongside your other Vask credentials. ### Verify in PHP / Laravel ```php $rawBody = $request->getContent(); $secret = (string) config('broadcasting.connections.pusher.secret'); $expected = hash_hmac('sha256', $rawBody, $secret); $received = $request->header('X-Pusher-Signature', ''); if (! hash_equals($expected, $received)) { abort(401); } $payload = json_decode($rawBody, true); foreach ($payload['events'] ?? [] as $event) { // Dispatch on $event['name'] } return response()->noContent(); ``` ### Verify in Node ```js import crypto from 'node:crypto'; export function verify(rawBody, signatureHeader, secret) { const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(signatureHeader, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` Capture the raw request body before any JSON parsing middleware mutates it, otherwise the signature will not match. ## Delivery guarantees and retries - **At-least-once delivery.** Your endpoint may receive the same event more than once. Make handlers idempotent (e.g. dedupe on `time_ms` plus event name and channel). - **2xx is success.** Any `2xx` response status, including `204 No Content`, is treated as a successful delivery. - **Non-2xx, network errors, and timeouts trigger retries** with backoff for a bounded window. After the retry window is exhausted the attempt is recorded as failed in the dashboard delivery log. - **No exactly-once semantics and no strict ordering.** Events for the same channel may arrive out of order, especially around retries. - **Bounded delivery logs.** The dashboard surfaces recent attempts (status, latency, attempt number, next retry, final failure) so you can spot misbehaving endpoints. Respond quickly. Do the minimum work needed to enqueue the payload and return `2xx`; defer downstream processing to a queue or background worker. ## Endpoint URL policy Webhook endpoints must use a public `https://` URL. Vask rejects plain HTTP, localhost/loopback URLs, private or link-local IPs, non-HTTP(S) protocols, and URLs containing credentials. ## Troubleshooting - **Signature mismatch:** confirm you are signing the **raw** body, not the re-encoded JSON, and that you are using the app secret matching `X-Pusher-Key`. - **Repeated retries:** check that your endpoint returns within the retry window and responds with `2xx` on success. Long-running synchronous work is the most common cause. - **Missing `client_event` deliveries:** verify the channel is private or presence, that client events are enabled in your client SDK, and that the `client_event` event type is enabled on the endpoint. - **No deliveries at all:** confirm the endpoint is enabled, the URL passes URL policy validation, and check the dashboard delivery log for the most recent attempt and its error. For a full client-side walkthrough see the [Laravel integration guide](/docs/laravel) or the [Setup with agent](/docs/agent) prompt. --- title: WebSockets on Cloudflare: Your Three Options in 2026 type: alternatives source: https://vask.dev/alternatives/cloudflare-websockets --- # WebSockets on Cloudflare: Your Three Options in 2026 You want to run [WebSockets](/glossary/websocket) on Cloudflare. Good instinct. The network is fast, the platform is well-built, and the operational track record is the one most other vendors are trying to inherit. The question is which of three different products you actually want, because "Cloudflare WebSockets" is not one thing. This page is the honest answer. The three options are real, they fit different shapes of project, and the right one for you depends on what you are building and how much of the substrate you want to own. ## Option 1: Workers + Durable Objects (DIY) This is the Cloudflare-native, build-it-yourself path. Workers terminate the WebSocket connection at the edge. Durable Objects provide the stateful coordination layer that makes a non-trivial real-time feature actually work (per-room state, presence tracking, channel membership, ordering guarantees). **What you get:** - Full control of the runtime. You write the connection handler, the message router, the auth layer, the disconnect cleanup, the reconnection coordination. - The Cloudflare network at no abstraction overhead. Connections terminate at the closest edge city; Durable Objects place themselves near the traffic. - Direct access to the rest of the Worker platform: KV, R2, D1, Queues, AI bindings. Real-time becomes one part of a larger Worker app. **What it costs you:** - **Engineering time.** Multi-room chat, presence indicators, [channel auth](/glossary/channel-auth), message replay, reconnection handling, exponential backoff: all of these are problems Pusher and Soketi solved in 2011. Solving them again on Durable Objects is a real project. Most teams underestimate it. - **Request and duration billing.** Cloudflare applies a documented 20:1 ratio for incoming WebSocket messages in request billing: 100 incoming messages count as 5 billable requests. Durable Objects also charge for active duration unless WebSocket hibernation applies. Model both, because duration can dominate. **Pick this when:** you have an unusual protocol need, you want to ship a real-time feature that is structurally different from channels-and-broadcasts, you have a strong team that wants to own the runtime, or you are building real-time as a core competence rather than as a means to ship a product feature. ## Option 2: Cloudflare Realtime SFU This one gets confused with the previous one because of the shared "realtime" word. Cloudflare Realtime is a Selective Forwarding Unit for WebRTC media. It routes audio and video between participants in a call. It is not a general-purpose WebSocket service. **What it is for:** video calls, audio calls, live streams with low-latency participation, WebRTC-based collaboration tools where users send media (not just messages) to each other. **What it is not for:** chat, presence, notifications, dashboards, multiplayer game state, typing indicators, live cursors. None of these are WebRTC media. None of these need an SFU. If your project involves audio or video in real-time, Cloudflare Realtime is a serious option and is outside the scope of this page. If your project involves any other kind of real-time messaging, skip this option and read on. ## Option 3: A Managed Pusher-Protocol Service on Cloudflare's Edge (Vask) This is the option that did not exist a few years ago and now does. A managed WebSocket service running on Cloudflare's edge network, speaking the [Pusher Channels protocol](/glossary/pusher-protocol) so your existing client SDKs work without modification. **What you get:** - **No Worker code, no Durable Object code, no DIY runtime.** You configure a host and credentials. Your application code calls a standard Pusher SDK (pusher-js, laravel-echo, pusher-php-server, `pusher-http-{language}`). The runtime is managed. - **The Cloudflare edge network as your real-time substrate.** Connections terminate on Cloudflare's edge network. - **Broadcast-priced, not Worker-priced.** A [broadcast](/glossary/broadcast) to a channel is one broadcast on the bill, regardless of subscriber count. You are not modeling Worker requests, Durable Object duration, hibernation behavior, or storage for the broadcast path. - **The Pusher protocol's SDK ecosystem.** Every major language has a maintained SDK. Documented client and server semantics. Drop-in compatible with anyone moving off Pusher Channels or Soketi. **What you trade off:** - You do not write the runtime. If your feature needs something the Pusher Channels protocol cannot express, this option is the wrong shape. (Most real-time features in production B2B and B2C apps fit cleanly inside the protocol's surface: [public channels](/glossary/public-channel), [private channels](/glossary/private-channel), [presence channels](/glossary/presence-channel), channel auth, broadcasts.) - You do not get direct access to the Workers platform from inside the broadcast pipeline. If you want a Worker-handled webhook to fire when a presence event occurs, you build that on your own application server (where you already have your business logic) rather than co-located in the broadcast handler. **Pick this when:** the goal is to ship a real-time feature, not to ship a real-time runtime. When channels and broadcasts and presence are the surface area, and the engineering hours you would spend on rooms-on-DOs are better spent on the product itself. When you want Cloudflare's edge without inheriting the build cost of using it directly. ## The decision in one frame ``` Are you building real-time as a core part of your product? | ┌───────────────┴───────────────┐ YES NO | | Is the feature shape Skip to managed (Option 3). channels + broadcasts + The build cost of DIY is not presence + channel auth? justified for one feature. | ┌────────┴────────┐ YES NO | | Managed Build on saves the Workers + weeks. Durable (Option 3) Objects. (Option 1) ``` The interesting case is the top-right of the tree: a team building real-time as a core product capability, on a use case that fits the Pusher protocol cleanly. For that team, managed-on-Cloudflare-edge is almost always the right call. The DIY option is justified when the use case does not fit the protocol, or when owning the runtime end-to-end is itself a strategic call. ## What Vask actually is Vask is a Pusher-protocol-compatible WebSocket service built on Cloudflare's edge. We did not invent a competing protocol because the Pusher Channels protocol already exists, is well-defined, and has a working SDK ecosystem. We did not build on AWS because the network and the operational story of Cloudflare's edge is the substrate we wanted to inherit. The receipts: - Pusher Channels protocol on the wire. Same SDKs your codebase already speaks - Cloudflare-edge delivery in 330+ cities - Broadcast-priced. No Worker/Durable Object bill to model, no per-[fan-out tax](/glossary/fan-out-tax) - Direct founder support on every paid tier - $10/mo entry tier (Side: 500 concurrent, 2M broadcasts). Indie at $20/mo. Pricing on the website If you are evaluating Cloudflare for real-time and the use case fits the protocol, this is the option that gets you to shipping without writing Worker code or wiring Durable Objects. ## When NOT to pick Vask on Cloudflare Three cases where we would point you at a different option: - **Your real-time feature does not fit the Pusher protocol.** WebRTC signaling, MQTT-shaped IoT messaging, a proprietary wire format you cannot change: any of these mean the protocol is not a wedge for you. Build on Workers + Durable Objects, or pick a service that speaks the protocol you need. - **You want to own the runtime as a strategic call.** Regulated industries, research projects, teams whose product IS the real-time substrate: DIY on Workers + Durable Objects is the right answer. We are not for you. - **You need WebRTC media routing.** That is Cloudflare Realtime SFU's job, not ours. If none of those apply, the math and the engineering-hours story work in your favor. ## Get going The Cloudflare edge as your real-time substrate, the Pusher protocol as your wire format, your existing SDKs as your client. --- title: PieSocket Alternative. Same Protocol. Multi-Region by Default. type: compare source: https://vask.dev/compare/piesocket-vs-vask --- # PieSocket Alternative. Same Protocol. Multi-Region by Default. Vask is a Pusher-protocol-compatible [WebSocket](/glossary/websocket) service, built on Cloudflare's edge in 330+ cities. Same wire protocol your PieSocket app already speaks. Same SDKs. The difference is the substrate and the bill shape: Cloudflare-edge delivery and broadcast-priced usage. ## Why developers leave PieSocket PieSocket solved a real problem in its moment: hosted Pusher-protocol service at a price below Pusher's own. For small projects in the US, that is still a good fit, and we would tell you so. The three reasons we hear from teams who do move: 1. **Edge architecture.** Vask starts from Cloudflare's edge network. PieSocket publishes its own multi-region and autoscaling story, so the honest comparison is not "global versus single region"; it is whether you want Vask's Cloudflare-edge implementation and Pusher-compatible billing model. 2. **The per-connection pricing math.** PieSocket's tiers are anchored on [concurrent connections](/glossary/concurrent-connections). That model works when your app is conversational (users connect, do a thing, disconnect). It fights you when your app is presence-driven or dashboard-shaped, because every user who has the page open is a billed connection whether they are doing anything or not. Vask prices broadcasts, which scales with what your app does, not who has a tab open. 3. **Operational fit.** Some teams want the small-provider relationship. Some want the Cloudflare-edge substrate. That is an architecture and procurement choice, not a moral one. None of this is a knock on PieSocket. It is a legitimate option for a specific scale and geography. The question is whether your scale and geography have moved past it. ## How Vask is different Three pieces of mechanism, no marketing varnish. **The [Pusher protocol](/glossary/pusher-protocol), kept on purpose.** PieSocket and Vask both implement the Pusher Channels protocol. Your client SDKs work unchanged when you switch. Laravel Echo, pusher-js, pusher-php-server, pusher-http-ruby, pusher-http-python: all of them connect to Vask the way they connect to PieSocket today. [Presence channels](/glossary/presence-channel) work. [Private channels](/glossary/private-channel) work. [Channel auth](/glossary/channel-auth) works. **Cloudflare's edge in 330+ cities.** Connections terminate on Cloudflare's edge network. No separate global tier to upgrade to, no per-region surcharge. **Broadcast billing, not per-connection billing.** A [broadcast](/glossary/broadcast) to a channel is one broadcast on the bill, regardless of how many subscribers are on it. There is no per-connection multiplier and no [fan-out tax](/glossary/fan-out-tax). Your typing indicator stops being a budget line item. Within your tier, we don't throttle, drop messages, or charge per subscriber copy. The receipts: - Cloudflare-edge delivery in 330+ cities - Pusher protocol drop-in. No SDK swap, no event-name rewrite, no auth rewrite - Broadcast-priced, not connection-priced. Presence and typing dots do not blow up the bill - Host and credential swap for Pusher-compatible apps - Direct founder support on every paid tier ## Side-by-side The table renders from a dated competitor fixture, so the numbers reflect PieSocket's published positioning as of the verification stamp. Refresh, do not trust. ## What the migration looks like Because both services speak the Pusher protocol, the migration is a host and credential swap. Everything that runs on top of the protocol keeps working. A few things worth saying out loud: - Nothing breaks at the SDK layer. The Pusher protocol is the contract. PieSocket and Vask both honor it. - Presence channels, private channels, and channel auth all work the same way. Same callback URLs, same signing pattern. - You can run both endpoints in parallel during the cutover. Feature flag, send a percentage of traffic to Vask, verify latency and bill in flight. Roll back at any time by flipping the env var. - If you have been using PieSocket-specific features outside the Pusher protocol (pre-published messages, webhooks-on-channel-events, custom REST routes), those need to be reimplemented on your own application server or in Vask Webhooks once that ships. The Pusher-protocol surface itself transfers cleanly. - Edge case in your stack? Email ashley@vask.dev and you will get an answer from someone who can read the diff. ## Run the numbers on your own bill The frame above is the argument. The number is what closes it. Plug in your concurrent users, your broadcasts per minute, and the average subscribers per channel, and the calculator will show you what each pricing model produces on your specific traffic shape. If you want a written breakdown for your team or your finance lead, the calculator will email you a formatted report keyed to your inputs. ## When NOT to switch Honesty matters on alternatives pages. Vask is not the right fit for every PieSocket user. - **Your traffic is small and your bill is already reasonable.** If you fit comfortably in PieSocket's current tiers, switching may not be worth the time. Stay. - **You are running PieSocket-specific features as load-bearing parts of your app.** If your architecture depends on PieSocket's pre-published messages, channel webhooks, or REST extensions outside the Pusher protocol, the switch involves reimplementing those, not just changing a host. The protocol surface transfers cleanly; the extensions do not. - **You are running PieSocket because the support relationship matters to you.** PieSocket has a small, responsive team and some shops value that. Vask also has direct founder support on every paid tier, but if you have a working relationship with PieSocket's team, that is real and we respect it. - **You need a non-Pusher protocol on the wire.** If your existing system is on a proprietary contract or a different protocol you cannot change, the protocol-compatibility wedge is not a wedge for you. If none of those apply, the numbers and the edge story will work in your favor. Run the calculator above and check. ## Get going Same protocol. Different network, different bill. Use Vask credentials, keep your existing SDKs, and point the client at `wss.vask.dev`. --- title: PubNub Alternative. Pusher Protocol. Cloudflare's Edge. type: compare source: https://vask.dev/compare/pubnub-vs-vask --- # PubNub Alternative. Pusher Protocol. Cloudflare's Edge. Vask is a Pusher-protocol-compatible [WebSocket](/glossary/websocket) service, built on Cloudflare's edge in 330+ cities. If you are using PubNub for channels, [broadcasts](/glossary/broadcast), and presence (the things most teams actually use PubNub for) Vask gives you that surface at startup prices with multi-region edge delivery built in. The migration is honest about itself: not a host swap, but a bounded SDK and event-mapping job that most teams complete in days, not weeks. ## Why developers leave PubNub PubNub is enterprise-shaped real-time infrastructure. It is well-built, well-supported, and priced for the segment it serves. The teams who leave are usually not using the whole surface. They picked it for live interactivity, then realized that their actual workload is simpler: publish to channels, subscribe from clients, and track presence. The three patterns we hear: 1. **The under-utilization audit.** Finance asks engineering why the PubNub line item is $1,400 a month. Engineering opens the dashboard and finds that the workload is "publish to one of six channels, subscribe from a JS client, occasional presence event." That is the Pusher protocol's surface area, and there are several services that implement it at one tenth the cost. 2. **The enterprise contract drag.** PubNub's commercial motion involves enterprise sales conversations, custom MSAs, and yearly renewals negotiated against transaction projections that are usually wrong in one direction or the other. For teams whose revenue is growing past the published Starter tier but who are not yet running operationally-critical real-time workloads, the contract overhead is a tax on focus. 3. **The feature-set ratchet.** Once you adopt PubNub Functions or Access Manager, the cost of leaving rises because some logic now lives in their platform. Teams that catch themselves drifting toward this and stop early are the ones who find migrating tractable. Teams that stay long enough to embed business logic in Functions face a harder rewrite later. The wedge for PubNub-leavers is not latency. PubNub's network is real. It is the bill, and the bill is structured for a different shape of buyer than the teams who feel it most acutely. ## How Vask is different Three pieces of mechanism, no marketing varnish. **The [Pusher protocol](/glossary/pusher-protocol), picked deliberately.** The Pusher Channels protocol is the cleanest channel-based real-time protocol in wide use. Open, documented, and supported by SDKs in every major language. PubNub built its own protocol and SDK family from scratch, which is a reasonable bet in 2010 and a harder one to justify in 2026 when the protocol you want already exists and has a working ecosystem. We chose to implement the protocol rather than invent one. Your future migration story (toward us or away from us) is portable because the protocol is portable. **Cloudflare's edge instead of a larger proprietary platform.** Connections terminate on Cloudflare's edge network. PubNub has its own global network; Vask's wedge is a smaller Pusher-protocol surface and a different bill shape, not a claim that PubNub lacks global infrastructure. **Startup-priced tiers, published openly.** $10/mo entry tier (Side: 500 concurrent, 2M broadcasts). $20/mo Indie. $100/mo Business. No sales call, no MSA, no transaction projection negotiation. If you outgrow the tiers we publish, we will talk; in the meantime, the bill is on the website. The receipts: - Cloudflare-edge delivery in 330+ cities - Pusher Channels protocol. Open, documented, portable to any future host that implements it - Tier pricing published openly. Indie at $20/mo for 2K concurrent + 10M broadcasts/month - Most teams ship the migration in days, not weeks. SDK swap is the main job - Direct founder support on every paid tier ## Side-by-side The table renders from a dated competitor fixture, so the numbers reflect PubNub's published positioning as of the verification stamp. Enterprise tiers are quote-based and intentionally not reflected as a single line item. ## What the migration looks like This is the section where we are not pretending the migration is a host swap. PubNub is its own protocol and SDK family. Vask is the Pusher protocol. The work is bounded but real. What changes: - **The client SDK.** Replace PubNub's JS SDK with pusher-js (or laravel-echo for Laravel apps). Both expose a subscribe-to-channel, listen-to-event pattern, but the API shapes differ. Rename the import, rename the event-binding calls. - **The server publish layer.** Replace PubNub's server SDK with pusher-http-php, pusher-http-ruby, pusher-http-python, or the equivalent for your stack. The publish call goes from `pubnub.publish({channel, message})` to `pusher.trigger(channel, event, payload)`. Event names become explicit; in PubNub they are typically implicit in the message body. - **Channel naming and auth.** Pusher [private channels](/glossary/private-channel) (prefix `private-`) and [presence channels](/glossary/presence-channel) (prefix `presence-`) replace PubNub's Access Manager rules for the same use cases. The [channel auth](/glossary/channel-auth) callback pattern is well-documented and most apps wire it up in an hour. - **Presence semantics.** Both services support presence. Vask's presence is via the Pusher protocol's `presence-*` channel with member join/leave events. Map the events; the semantic shape is close. What does not change: - Your business logic in the application server. The channel publish becomes a different method call, but the conditions under which you publish (a user did X, an event Y happened) are app code, not infrastructure code. - Your application's data model around real-time. Channels-and-subscribers-and-presence is a universal pattern; the names of the methods change, the shape of the architecture does not. A realistic timeline for most teams: a week to plan the protocol mapping and rewrite the SDK calls, a week to run both endpoints in parallel via a feature flag and verify behavior on a portion of production traffic, then a cutover when confidence is there. Larger codebases with PubNub-specific features outside the channel-broadcast-presence surface take longer in proportion to those features. If the migration runs into an edge case in your stack, email ashley@vask.dev and you will get an answer from someone who can read your diff. ## Run the numbers on your own bill The frame above is the argument. The number is what closes it. Plug in your concurrent users, your broadcasts per minute, and the average subscribers per channel, and the calculator will show you what each pricing model produces on your specific traffic shape. If you want a written breakdown for your team or your finance lead, the calculator will email you a formatted report keyed to your inputs. ## When NOT to switch Honesty is load-bearing on alternatives pages. The cases where we would tell you to stay on PubNub: - **You are using PubNub Functions as a serverless layer.** Functions is a real product surface and Vask does not have an equivalent. If meaningful business logic runs inside PubNub Functions, the rewrite is not bounded by SDK swap; it is bounded by reimplementing serverless functions in your own stack or another provider. That can still be the right call, but go in with eyes open about scope. - **You depend on PubNub Access Manager for fine-grained authorization.** Pusher protocol's private and presence channels cover most authorization needs, with a callback on your application server. If your auth model is built around PubNub's per-grant Access Manager rules, the migration involves rebuilding that logic on your application server, which is feasible but is its own project. - **You use PubNub Files, Mobile Push, or Storage as load-bearing parts of the stack.** These are not parts of the Pusher Channels protocol surface and Vask does not provide them. Solve them separately if you want to migrate channels-only. - **Your enterprise contract includes commitments Vask cannot match.** Custom SLAs beyond 99.99%, specific compliance certifications, 24/7 phone support on contract. We support paid tiers with direct founder responsiveness, which is enough for most teams and is not the right thing for some. - **You are below the PubNub free tier and paying nothing.** Stay where you are until you have a bill. Switching to save money you are not spending is wasted migration time. If none of those apply, the math and the architecture story work in your favor. The audit usually surprises the team that runs it. ## Get going Edge-native channels and broadcasts on a protocol your future team will recognize. No enterprise contract to negotiate, no Functions to depend on, no per-grant authorization rules to map. Three pieces of mechanism, published prices, founder-built. --- title: Pusher Alternative. Same Protocol. Cloudflare's Edge. type: compare source: https://vask.dev/compare/pusher-vs-vask --- # Pusher Alternative. Same Protocol. Cloudflare's Edge. Vask is a Pusher-protocol-compatible [WebSocket](/glossary/websocket) service, re-architected for Cloudflare's edge in 330+ cities. Your sockets terminate at the closest edge instead of one home region, while your existing Pusher-protocol SDKs keep the same channel and event model. The bill changes because Vask prices broadcasts instead of subscriber copies. ## Why developers leave Pusher The reason is rarely latency. It is rarely uptime. It is the bill, and the bill has a specific mechanism that nobody warns you about until you cross a threshold. The mechanism is the per-fan-out multiplier. Here is how it works in plain terms. You publish one message to a channel. That channel has 50 subscribers. The pricing model counts the [broadcast](/glossary/broadcast) as 51 messages: one publish, 50 deliveries. Add another subscriber and it becomes 52. Run a notifications channel with 1,000 connected users and a single broadcast becomes 1,001 billable messages. Run a typing-indicator over a busy room and you can rack up tens of thousands of billable messages from a single user pressing a key. This is not a quirk. It is documented in the protocol vendor's own engineering reference: So the bill scales with the thing you cannot control (the number of people listening) rather than the thing you can control (how often you publish). The category has a name for the workload: "fan-out." We call the surcharge attached to it the [fan-out tax](/glossary/fan-out-tax). Pricing pages do not say "fan-out tax" because pricing pages do not name the mechanism. The mechanism is the product. There are three predictable moments when developers feel this and start shopping for an alternative: 1. **The viral moment.** A feature works. Subscribers grow ten times overnight. The bill grows a hundred times overnight because every existing broadcast now fans out across ten times the audience and the per-message overage rate stacks on top. 2. **The product change.** Someone ships presence indicators, typing indicators, online-status dots, or live cursor positions. Each of these emits broadcasts at human-keystroke or human-mouse-move frequency, fanned out across every viewer. Costs per active user can rise by an order of magnitude in a week. 3. **The audit.** Finance pulls a year of invoices, asks why the line item doubles every quarter, and the engineering lead has to explain that "broadcasting to your own users is the priced unit, and the price scales with success." We don't think any of this is a moral failing of the incumbent. It is just a billing model from a previous era of real-time, when broadcasts were rare and connections were the scarce resource. What changed is the work pattern. Modern apps fan out constantly. The model has not caught up. ## How Vask is different Three pieces of mechanism, no marketing varnish. **The [Pusher protocol](/glossary/pusher-protocol), kept on purpose.** The Pusher wire protocol is one of the cleanest channel-based real-time protocols in the wild. It has good SDKs in every language a working developer is likely to use. It has well-defined semantics for [public channels](/glossary/public-channel), [private channels](/glossary/private-channel), [presence channels](/glossary/presence-channel), and [channel auth](/glossary/channel-auth). We did not invent a competing protocol because there is no reason to invent one. We kept the protocol. Your client SDKs work unchanged. Your server SDKs work unchanged. Laravel Echo, pusher-js, pusher-php-server, pusher-http-ruby, pusher-http-python: all of them connect to Vask the same way they connect today. **Cloudflare's edge instead of a selected region.** Connections terminate on Cloudflare's edge network, not a single home region. There is no separate "global" tier to upgrade to and no per-region charge. **Flat broadcast billing.** A broadcast to a channel is one broadcast on the bill, regardless of how many subscribers are on it. There is no per-fan-out multiplier. There is no surcharge for presence channels with thousands of members. There is no surprise line item for the typing indicator that ships in a sprint. We don't throttle, drop, or charge for [fan-out](/glossary/fan-out). The receipts: - Cloudflare-edge delivery in 330+ cities - Pusher protocol drop-in. No SDK swap, no event-name rewrite, no auth rewrite - Broadcast-priced billing. The calculator on this page shows the math against your own inputs, which can land higher or lower - Host and credential swap for Pusher-compatible apps - Built by people who ship. Answered by the same people. ## Side-by-side The table renders from a dated competitor fixture, so the numbers reflect published pricing as of the last verification stamp on the row. If a tier has shifted since then, the timestamp tells you so. Refresh, do not trust. ## What the migration looks like The wedge is the protocol. Because Vask speaks the Pusher Channels protocol, you do not rewrite channel names, event names, payload shapes, presence semantics, or channel auth. The change is at the configuration layer. Point your existing real-time client and server SDK at the Vask host and supply Vask credentials. The Laravel example below is illustrative; there are also framework-specific recipes for Laravel, Rails, Next.js, and Django. A few things worth saying out loud: - Nothing breaks at the SDK layer. The Pusher protocol is the contract. The protocol does not know which host is on the other end of the WebSocket. - Presence channels work. Private channels work. Channel auth works. We use the same auth pattern. - You can run both endpoints in parallel during the cutover. Spin up a feature flag, send a percentage of traffic to Vask, and verify the bill against your existing one in flight. Roll back at any time by flipping the env var. - If the migration runs into an edge case in your stack, email ashley@vask.dev and you will get an answer from someone who can read the diff. ## Run the numbers on your own bill The frame above is the argument. The number is what closes it. Plug in your concurrent users, your broadcasts per minute, and the average subscribers per channel, and the calculator will show you what the per-fan-out multiplier is doing to your current bill, and what it would look like without it. If you want a written breakdown for your team or your finance lead, the calculator will email you a formatted report keyed to your inputs. Same numbers, different format. Use it as the supporting doc for the switch decision. ## When NOT to switch Honesty is a load-bearing part of an alternatives page. Vask is not the right fit for everyone, and we would rather you stay on what you have than pay for something you do not need. The cases below are the ones where we would tell you not to switch: - **You are below the free tier and paying nothing.** If your concurrent connection count and message volume sit comfortably inside the incumbent's free plan, switching does not save money. It costs you migration time you could spend on the product. Come back when the bill shows up. - **You are running Supabase as a bundle.** If you are using Supabase auth + Postgres + storage + realtime as one stack and the bundled real-time channel is fine for what you are doing, the value is in the bundle. Pulling realtime out alone breaks the integration math and replaces a single bill with two. Don't. - **You are running Laravel Reverb in-process for a small app, and the ops are fine.** Reverb is a solid first-party Laravel option that runs alongside your app server, with no per-message billing and no extra vendor in the stack. If your traffic fits inside one server, your team is comfortable operating it, and you do not need multi-region edge presence, Reverb is the right answer for you. Vask is the hosted Pusher-protocol option for teams that want the protocol without operating their own WebSocket server, or that need edge delivery beyond what an in-process server can give them. - **You are evaluating real-time as a science project, not for production.** If the goal is "I want to learn how WebSockets work end-to-end," Vask is not the teaching surface. Read the protocol spec, run a server locally, then come back when the question is "what hosts this in production." - **You need a non-Pusher protocol on the wire.** If your existing system is on Ably's protocol, MQTT, or a proprietary contract you cannot change, the protocol-compatibility wedge is not a wedge for you. The Pusher protocol is the through-line of the migration story. If none of those apply, the math will work in your favor. Run the calculator above and check. ## Get going Same protocol. Different bill. Use Vask credentials, keep your existing SDKs, and remove the per-fan-out multiplier from your invoice. --- title: Soketi Alternative. Same Protocol. Without the Pager. type: compare source: https://vask.dev/compare/soketi-vs-vask --- # Soketi Alternative. Same Protocol. Without the Pager. Vask is a hosted Pusher-protocol [WebSocket](/glossary/websocket) service, built on Cloudflare's edge in 330+ cities. Same wire protocol your Soketi clients already speak. Same SDKs. Your sockets terminate on managed edge infrastructure instead of a server you provision. No CVEs to track. No upgrade window to schedule. ## Why developers leave Soketi You picked Soketi for a real reason. The most common one is the bill. Pusher's per-[fan-out](/glossary/fan-out) billing model charges per subscriber copy of a [broadcast](/glossary/broadcast), so a single message to a channel of 1,000 listeners is 1,001 billable messages. Across a busy app with presence indicators, typing dots, or live cursors, that math is what drives teams to self-host. Soketi is the most popular open-source answer to that math, and it works. What it does not solve is the operational cost of running it. The four things we hear repeatedly: 1. **The pager.** Self-host means somebody is on call when the WebSocket server falls over. For one-region apps with a small ops team, that is a real burden you priced in when you chose Soketi. For teams whose product is not WebSocket infrastructure, the burden compounds quietly. Every CVE patch, every node upgrade, every load test that needs a fresh staging cluster is engineering time not spent on the product. 2. **The multi-region problem.** Soketi runs as a single process. Multi-region Soketi means running multiple regional clusters with a shared pubsub layer (typically Redis), and a careful topology that handles cross-region message routing. Most Soketi shops do not do this. They run one region, accept the latency penalty for users on the other side of an ocean, and tell themselves they will fix it later. Later rarely arrives. 3. **The maintenance cadence.** The Soketi main repository's maintenance has slowed publicly. The maintainer has said in repository discussion that maintenance is "tight as hell." A Rust rewrite (Sockudo) is in progress in the community. None of this is a problem if you run a static install of Soketi that does what you need; it becomes a problem the moment a CVE lands or a protocol-level edge case surfaces and you need a patch this week. 4. **No SLA.** Community OSS is not contractually accountable for your downtime. For a side project that is fine. For a B2B product whose customer contracts require uptime commitments, the lack of an SLA is the conversation finance has with engineering when the renewal lands. None of this is a moral failing of Soketi. Soketi is a well-built piece of open source that solved a real problem in its moment. The question is whether the trade (escape the per-fan-out bill, accept the ops cost) is still the right trade in 2026 when there is a third option. ## How Vask is different Three pieces of mechanism, no marketing varnish. **The [Pusher protocol](/glossary/pusher-protocol), kept on purpose.** Soketi and Vask both implement the Pusher Channels protocol. That is not a coincidence; it is the wedge. The Pusher wire protocol has good SDKs in every language a working developer is likely to use, well-defined semantics for [public channels](/glossary/public-channel), [private channels](/glossary/private-channel), [presence channels](/glossary/presence-channel), and [channel auth](/glossary/channel-auth). Soketi proved the protocol was worth implementing as open source. Vask proves it is worth implementing as a managed service on the edge. Your client SDKs work unchanged when you switch. Laravel Echo, pusher-js, pusher-php-server, pusher-http-ruby, pusher-http-python: all of them connect to Vask the same way they connect to Soketi today. **Cloudflare's edge instead of a droplet.** Connections terminate on Cloudflare's edge network, not the region where your VPS lives. Multi-region is the default, not a topology you build. **No WebSocket server to operate.** We patch the CVEs, do the version upgrades, and own the load balancer. Support is direct; the person who can read your stack trace answers your email. The receipts: - Cloudflare-edge delivery in 330+ cities - Pusher protocol drop-in. No SDK swap, no event-name rewrite, no auth model rewrite - Multi-region by default. No cluster topology to build, no Redis pubsub to operate - Host and credential swap for Pusher-compatible apps - Built by people who ship. Answered by the same people. ## Side-by-side The table renders from a dated competitor fixture, so the numbers reflect Soketi's published positioning and typical self-host infra cost as of the verification stamp on the row. Refresh, do not trust. ## What the migration looks like Because both implementations speak the Pusher protocol, the migration is a host and credential swap. Everything that runs on top of the protocol keeps working. A few things worth saying out loud: - Nothing breaks at the SDK layer. The Pusher protocol is the contract. Soketi and Vask both honor it. The SDK does not know which host is on the other end of the WebSocket. - Presence channels work. Private channels work. Channel auth works. Same auth pattern, same callback URLs on your app server. - You can run both endpoints in parallel during the cutover. Spin up a feature flag, send a percentage of traffic to Vask, and verify the latency and bill against your existing Soketi cluster in flight. Roll back at any time by flipping the env var. - If your Soketi deployment has accumulated configuration drift (custom event types, non-default channel name conventions, custom auth signing), the protocol behavior is the same on Vask; nothing about your application code needs to know it moved. - If the migration runs into an edge case in your stack, email ashley@vask.dev and you will get an answer from someone who can read the diff. ## When NOT to switch Honesty matters more here than it does on most alternatives pages, because self-host is a legitimate choice and we respect why you made it. The cases where we would tell you to stay on Soketi: - **Your install is small, stable, single-region, and the ops are minimal.** If you run one Soketi node behind a load balancer, it has not woken anyone up in six months, and your traffic fits inside it comfortably, do not switch. Vask is the answer when the operational tax outweighs the subscription cost, not before. - **You self-host on principle.** Regulated industries, government work, data-sovereignty requirements, procurement mandates around stack control, or simply a strong preference for owning the runtime: all real. None of this is what Vask serves. Stay on Soketi; consider Sockudo when the Rust rewrite stabilizes if performance is the next constraint. - **You are using Soketi as a teaching surface.** If the goal is "I want to understand how a WebSocket server works end-to-end at the protocol level," Soketi's source is the teaching surface. Read it. Run it. Vask is for production, not learning. - **Your traffic shape genuinely fits inside one server and the bill on a managed service is not better than your droplet.** Some apps run mostly idle WebSockets at low fan-out. The Pusher-bill argument that pushed you to Soketi may not apply at your scale. The numbers above will tell you which side of the line you are on. If none of those apply, the operating story works in your favor. The same protocol your code already speaks, on infrastructure you do not have to operate. ## Get going Same protocol. No droplet, no WebSocket server to maintain. Use Vask credentials, keep your existing client SDKs, and let the edge handle the rest. --- title: Migrate from PieSocket to Vask (Laravel). Drop-in host swap. type: migrate source: https://vask.dev/migrate/piesocket-to-vask-laravel --- # Migrate from PieSocket to Vask (Laravel). Drop-in host swap. PieSocket and Vask both speak the [Pusher protocol](/glossary/pusher-protocol). A Laravel app using the `pusher` broadcast connection keeps its events, Echo subscriptions, and channel auth logic; only the host and credentials change. ## Minimal cutover Save the current PieSocket values first. Then update the env block. If your app still uses `BROADCAST_DRIVER`, keep that name and set it to `pusher`. ```diff BROADCAST_CONNECTION=pusher -PUSHER_APP_ID=your_piesocket_app_id -PUSHER_APP_KEY=your_piesocket_key -PUSHER_APP_SECRET=your_piesocket_secret -PUSHER_HOST=ws.piesocket.com +PUSHER_APP_ID=your_vask_key +PUSHER_APP_KEY=your_vask_key +PUSHER_APP_SECRET=your_vask_secret +PUSHER_HOST=wss.vask.dev PUSHER_PORT=443 PUSHER_SCHEME=https -PUSHER_APP_CLUSTER=v3 +PUSHER_APP_CLUSTER=mt1 VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" ``` If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ## Rollback Put the saved PieSocket values back, redeploy or rebuild the client bundle if `VITE_PUSHER_*` values are embedded, and clear config cache. Rollback is only credentials and endpoint state. ## Laravel package shortcut ```bash composer require vask/laravel php artisan vask:install ``` The installer writes the same Pusher-compatible env values and runs `php artisan vask:doctor`. Source: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel) · [packagist](https://packagist.org/packages/vask/laravel). ## Server publish Confirm `config/broadcasting.php` reads host from env: ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST'), 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], ], ``` Replace any hardcoded `ws.piesocket.com` host with `env('PUSHER_HOST')`. ## Client subscribe Echo should read the same Vite env values: ```js window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], }); ``` ## Private and presence channels Keep `routes/channels.php`, `/broadcasting/auth`, and existing `private-` / `presence-` channel names. The auth signature stays Pusher-compatible; only the secret used for signing changes. ## Verify 1. Deploy the env change to staging. 2. Confirm the browser opens a [WebSocket](/glossary/websocket) to `wss.vask.dev`, not PieSocket. 3. Trigger one Broadcast event and verify the client receives it. 4. Verify one private or presence subscription returns 200 from `/broadcasting/auth`. ## Gotchas - **Host and cluster mismatch.** `PUSHER_APP_CLUSTER=v3` does not route Vask traffic. `PUSHER_HOST=wss.vask.dev` is the important value. - **Mixed credentials.** A Vask key with a PieSocket secret, or the reverse, usually appears as publish failures or private-channel 403s. - **Stale Vite bundle.** The browser may keep connecting to PieSocket until the client bundle is rebuilt. - **Auth endpoint assumptions.** Custom auth endpoints can stay, but they must sign with the Vask secret after the env swap. ## Where to go next - Read the [PieSocket vs Vask comparison](/compare/piesocket-vs-vask) for architecture and pricing context. - Check the [Laravel docs](/docs/laravel) for advanced setup. - Run the [fan-out calculator](/#calculator) if cost is the migration driver. ## Get going Keep the channel code. Swap host and credentials. Verify authenticated channels before production traffic moves. --- title: Migrate from PubNub to Vask (Laravel). SDK Swap, Open Protocol. type: migrate source: https://vask.dev/migrate/pubnub-to-vask-laravel --- # Migrate from PubNub to Vask (Laravel). SDK Swap, Open Protocol. PubNub to Vask is an SDK swap, not a host swap. Keep the business logic that reacts to messages; replace the transport with Laravel Broadcasting, laravel-echo, and pusher-js. ## Feature mapping Vask targets the Pusher Channels protocol surface. Map PubNub features before changing code. | PubNub concept | Vask / Laravel equivalent | Migration note | | ------------------------- | ------------------------------------------- | --------------------------------------------------------------- | | Publish message | `broadcast(new Event(...))` | Move publish call sites to Laravel events. | | Subscribe to channel | `Echo.channel(...).listen(...)` | Move handlers from PubNub listeners to Echo listeners. | | Private channel access | `routes/channels.php` auth callback | Replace PubNub tokens or PAM checks with Laravel authorization. | | Presence | `Echo.join(...).here().joining().leaving()` | Same user-awareness goal, different API semantics. | | Client-originated events | Echo `whisper` on private/presence channels | Use only for ephemeral client events such as typing. | | Functions on publish path | Laravel service, listener, or queued job | Move enrichment/filtering into your application. | | Message history / storage | Your database or existing persistence layer | Not part of this channels cutover. | ## Channel and event mapping PubNub usually combines a flat channel name with a message payload field such as `type`. In Laravel Broadcasting, channel name and event name are separate. | PubNub shape | Laravel / Echo call | Pusher wire channel | | ------------------------ | -------------------------- | ------------------- | | `orders` | `Echo.channel('orders')` | `orders` | | `user.123` private feed | `Echo.private('user.123')` | `private-user.123` | | `room.456` presence room | `Echo.join('room.456')` | `presence-room.456` | Use `broadcastAs()` for explicit event names. Echo listeners for custom names need the leading dot: ```php public function broadcastAs(): string { return 'order.created'; } ``` ```js Echo.channel('orders').listen('.order.created', (event) => { handleOrderCreated(event); }); ``` If your PubNub payload was `{ type: 'order.created', order_id: 123 }`, the Pusher event name becomes `order.created` and the handler receives the payload directly. ## Out of scope Keep these PubNub surfaces out of the Vask channels cutover: | PubNub surface | Recommended path | | ----------------------------- | ----------------------------------------------------- | | Functions | Move logic into Laravel before broadcasting. | | Access Manager / PAM | Replace with Laravel policies and channel auth. | | Message Persistence / history | Store messages in your database before broadcasting. | | Files | Keep existing file storage or move to object storage. | | Mobile Push | Keep your push provider or migrate separately. | ## Progressive rollout Migrate by feature, not by whole application. 1. Inventory PubNub channels, message types, presence usage, and any Functions on the publish path. 2. Pick one low-risk feature or channel family. 3. Add the Vask Broadcast event and Echo listener while PubNub remains live for everything else. 4. Optionally dual-publish for that feature in staging or behind a feature flag. 5. Switch the client surface to Echo, verify, then remove that feature's PubNub publish and subscribe path. 6. Repeat for the next feature. This keeps rollback local. A failed feature can return to the PubNub path without touching unmigrated features. ## Laravel setup Use the Vask installer if you want the shortest Laravel Broadcasting setup: ```bash composer remove pubnub/pubnub-php composer require vask/laravel php artisan vask:install ``` Manual setup is also fine: ```bash php artisan install:broadcasting --pusher composer require pusher/pusher-php-server ``` Set Vask credentials in `.env`. If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ```env BROADCAST_CONNECTION=pusher PUSHER_APP_ID=your_vask_key PUSHER_APP_KEY=your_vask_key PUSHER_APP_SECRET=your_vask_secret PUSHER_HOST=wss.vask.dev PUSHER_PORT=443 PUSHER_SCHEME=https PUSHER_APP_CLUSTER=mt1 VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" ``` Confirm the `pusher` connection points at Vask: ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'host' => env('PUSHER_HOST', 'wss.vask.dev'), 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => true, ], ], ``` ## Server publish Replace PubNub publish calls: ```php $pubnub->publish() ->channel('orders') ->message(['type' => 'order.created', 'order_id' => $id]) ->sync(); ``` With a Laravel Broadcast event: ```php use Illuminate\Broadcasting\Channel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; class OrderCreated implements ShouldBroadcast { /** * @param array{order_id: int, status: string} $order */ public function __construct(public array $order) {} public function broadcastOn(): array { return [new Channel('orders')]; } public function broadcastAs(): string { return 'order.created'; } /** * @return array{order_id: int, status: string} */ public function broadcastWith(): array { return $this->order; } } ``` ```php broadcast(new OrderCreated([ 'order_id' => $id, 'status' => 'created', ])); ``` ## Client subscribe Replace PubNub listeners: ```js const pubnub = new PubNub({ publishKey, subscribeKey }); pubnub.addListener({ message: ({ message }) => { if (message.type === 'order.created') { handleOrderCreated(message); } }, }); pubnub.subscribe({ channels: ['orders'] }); ``` With Echo over pusher-js: ```js window.Pusher = Pusher; window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: true, enabledTransports: ['ws', 'wss'], cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, }); window.Echo.channel('orders').listen('.order.created', (event) => { handleOrderCreated(event); }); ``` Your handler logic stays. The subscription API changes. ## Private and presence channels Move PubNub access checks to Laravel channel authorization: ```php use App\Models\User; use Illuminate\Support\Facades\Broadcast; Broadcast::channel('user.{id}', function (User $user, int $id): bool { return $user->id === $id; }); Broadcast::channel('room.{id}', function (User $user, int $id): array|false { if (! $user->canJoinRoom($id)) { return false; } return ['id' => $user->id, 'name' => $user->name]; }); ``` ```js window.Echo.private(`user.${userId}`).listen( '.notification.sent', handleNotification, ); window.Echo.join(`room.${roomId}`) .here(setPresentUsers) .joining(addPresentUser) .leaving(removePresentUser); ``` ## Verify Before production cutover: 1. Trigger one public event and confirm the browser receives it from `wss.vask.dev`. 2. Trigger one private event and confirm unauthorized users get a failed auth response. 3. Join one presence channel and confirm `here`, `joining`, and `leaving` match your UI expectations. 4. Compare payload shape against the old PubNub handler. 5. Test reconnect after network interruption. 6. If dual-publishing, confirm duplicate events do not reach production users. ## Rollback This is not an env-only rollback. Keep the PubNub code path until each migrated feature is stable. For a migrated feature, rollback by disabling the Echo subscription, restoring the PubNub subscription, and sending server publishes back through the PubNub SDK. If you use feature flags, keep one flag for server publish path and one for client subscribe path so you can unwind safely. ## Gotchas - **Payload envelope changes.** PubNub listeners receive a `message` envelope. Echo listeners receive the event payload directly. - **Custom event names need a dot.** If Laravel `broadcastAs()` returns `order.created`, listen with `.order.created`. - **Presence state is not method-compatible.** PubNub `hereNow()` and `getState()` map to Echo callbacks and your own persisted state. - **Functions must move first.** If a PubNub Function mutates an event, move that logic into Laravel before switching the channel. ## Where to go next - Read the [PubNub vs Vask comparison](/compare/pubnub-vs-vask) for product-surface and billing differences. - Run the [fan-out calculator](/#calculator) against your workload. - Check the [Laravel docs](/docs/laravel) for `vask:doctor`, webhooks, and the local round-trip demo. If your migration runs into something this page does not cover, email ashley@vask.dev and you will get an answer from someone who can read your code. ## Get going Replace the PubNub SDK feature by feature, keep the message-handling logic, and move delivery to the open Pusher Channels protocol at the Vask edge. --- title: Migrate from Pusher to Vask (Django). Drop-in for pusher-http-python. type: migrate source: https://vask.dev/migrate/pusher-to-vask-django --- # Migrate from Pusher to Vask (Django). Drop-in for pusher-http-python. Vask speaks the [Pusher protocol](/glossary/pusher-protocol), so a Django app using `pusher-http-python` and `pusher-js` usually migrates by changing credentials, host, and the frontend bundle. ## Minimal config diff Set the Vask app key as both `PUSHER_APP_ID` and `PUSHER_APP_KEY`. If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ```diff - PUSHER_APP_ID=123456 - PUSHER_APP_KEY=abc123 - PUSHER_APP_SECRET=xyz789 - PUSHER_APP_CLUSTER=us3 + PUSHER_APP_ID=your_vask_key + PUSHER_APP_KEY=your_vask_key + PUSHER_APP_SECRET=your_vask_secret + PUSHER_HOST=wss.vask.dev + PUSHER_PORT=443 + PUSHER_APP_CLUSTER=mt1 ``` Keep one server-side client and add the explicit host: ```python from pusher import Pusher pusher_client = Pusher( app_id=os.environ["PUSHER_APP_ID"], key=os.environ["PUSHER_APP_KEY"], secret=os.environ["PUSHER_APP_SECRET"], host=os.environ.get("PUSHER_HOST", "wss.vask.dev"), port=int(os.environ.get("PUSHER_PORT", 443)), ssl=True, cluster=os.environ.get("PUSHER_APP_CLUSTER", "mt1"), ) ``` ## Rollback Restore the old Pusher environment values, remove or override `PUSHER_HOST`, redeploy the frontend bundle with your normal pipeline, and restart Django workers. No package rollback is required. ## What changes / what stays - Changes: host, key, secret, optional compatibility cluster, and any compiled client config. - Stays: `pusher_client.trigger`, channel names, event names, `pusher-js`, auth signatures, and any existing Django URL for channel auth. ## Server publish Keep existing trigger calls: ```python from myproject.pusher_client import pusher_client pusher_client.trigger("orders", "order.created", {"id": order.id}) ``` Instantiate the client once in `settings.py` or a small `pusher_client.py` module. Per-view construction works, but it makes cutover verification noisier. ## Client subscribe Expose only browser-safe values, then point pusher-js at Vask: ```js function getCookie(name) { const match = document.cookie.match( new RegExp('(^| )' + name + '=([^;]+)'), ); return match ? decodeURIComponent(match[2]) : null; } const pusher = new Pusher(window.VASK_PUSHER.key, { cluster: window.VASK_PUSHER.cluster || 'mt1', wsHost: window.VASK_PUSHER.host || 'wss.vask.dev', wsPort: 443, wssPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], authEndpoint: '/pusher/auth/', auth: { headers: { 'X-CSRFToken': getCookie('csrftoken'), }, }, }); ``` Do not expose `PUSHER_APP_SECRET` to templates, Vite, Webpack, or any public `window` object. ## Private and presence channels The auth view still signs the same Pusher-compatible payload: ```python from django.http import HttpResponseForbidden, JsonResponse from django.views.decorators.http import require_POST from myproject.pusher_client import pusher_client @require_POST def pusher_auth(request): if not request.user.is_authenticated: return HttpResponseForbidden() auth = pusher_client.authenticate( channel=request.POST["channel_name"], socket_id=request.POST["socket_id"], custom_data={ "user_id": str(request.user.id), "user_info": {"name": request.user.get_username()}, }, ) return JsonResponse(auth) ``` Prefer sending `X-CSRFToken` from the browser. If you mark the view `csrf_exempt`, keep session or token authorization inside the view. ## Verify 1. Deploy or restart Django with the new environment. 2. Hard refresh the browser and confirm a WebSocket connects to `wss.vask.dev`. 3. Trigger `pusher_client.trigger` from a view, Celery task, or `python manage.py shell`. 4. Test one public channel and one private or presence channel before production cutover. ## Gotchas - **CSRF 403s.** Confirm the `csrftoken` cookie exists, the `X-CSRFToken` header is sent, and `request.user.is_authenticated` is true. - **Channels still mounted.** ASGI Channels routes can coexist with Vask during migration. Remove them later only if no clients use them. - **Bundle cache still has Pusher.** Rebuild or redeploy the client bundle and hard refresh if the browser still connects to a Pusher host. - **Cluster expectations.** Vask routes by `PUSHER_HOST`, not cluster. Keep `mt1` only for SDK compatibility. ## Where to go next - Run the [Pusher fan-out calculator](/#calculator) against your workload. - Read the [Pusher vs Vask comparison](/compare/pusher-vs-vask) for pricing and protocol detail. - Check the [Django guide](/learn/websockets-in-django) for advanced setup. - If another service shares events, compare the [Laravel](/migrate/pusher-to-vask-laravel) and [Rails](/migrate/pusher-to-vask-rails) recipes. ## Get going Vask keeps the Django migration at the credential layer: same package, same client, same auth view, new host. --- title: Migrate from Pusher to Vask (Laravel). Drop-in host swap. type: migrate source: https://vask.dev/migrate/pusher-to-vask-laravel --- # Migrate from Pusher to Vask (Laravel). Drop-in host swap. Vask speaks the [Pusher protocol](/glossary/pusher-protocol), so a standard Laravel Broadcasting app keeps the `pusher` connection, `laravel-echo`, `pusher-js`, Broadcast events, and channel auth flow. The cutover is credentials plus host. ## Minimal cutover Save your current Pusher values first. Then change the Laravel broadcast env block. If your app still uses `BROADCAST_DRIVER`, keep that name and set it to `pusher`. ```diff BROADCAST_CONNECTION=pusher -PUSHER_APP_ID=123456 -PUSHER_APP_KEY=abc123 -PUSHER_APP_SECRET=xyz789 -PUSHER_HOST= +PUSHER_APP_ID=your_vask_key +PUSHER_APP_KEY=your_vask_key +PUSHER_APP_SECRET=your_vask_secret +PUSHER_HOST=wss.vask.dev PUSHER_PORT=443 PUSHER_SCHEME=https -PUSHER_APP_CLUSTER=us3 +PUSHER_APP_CLUSTER=mt1 VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" ``` If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ## Rollback Put the saved Pusher values back, redeploy or rebuild the client bundle if `VITE_PUSHER_*` values are baked in, and clear config cache if you cache Laravel config during deploy. Rollback is symmetric because no app code or schema changes are required. ## Laravel package shortcut ```bash composer require vask/laravel php artisan vask:install ``` The installer writes the same Pusher-compatible env values and runs `php artisan vask:doctor`. Source: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel) · [packagist](https://packagist.org/packages/vask/laravel). ## Server publish Confirm `config/broadcasting.php` reads the Pusher host from env: ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], ], ``` If `host` is hardcoded to a Pusher cluster host, change it to read `PUSHER_HOST`. ## Client subscribe Confirm Echo reads the Vite env values: ```js window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], }); ``` ## Private and presence channels Keep your existing `routes/channels.php` callbacks and `/broadcasting/auth` route. Public channels, `Echo.private(...)`, and `Echo.join(...)` should keep the same names and event listeners. ## Verify 1. Deploy the env change to staging. 2. Confirm the browser opens a [WebSocket](/glossary/websocket) to `wss.vask.dev`. 3. Trigger one Broadcast event and verify the client receives it. 4. Subscribe to one private or presence channel and confirm `/broadcasting/auth` returns 200. ## Gotchas - **Stale Vite bundle.** Server publishes may hit Vask while the browser still connects to Pusher until the client bundle is rebuilt. - **Old cluster assumptions.** Keep `PUSHER_APP_CLUSTER` only as a compatibility placeholder; `PUSHER_HOST=wss.vask.dev` routes traffic. - **Custom auth route.** If you changed `/broadcasting/auth`, make sure Echo still points at the same route after deploy. - **Whitespace in secrets.** A copied newline in `PUSHER_APP_SECRET` usually shows up as private-channel 403s. ## Where to go next - Read the [Pusher vs Vask comparison](/compare/pusher-vs-vask) for architecture and pricing context. - Check the [Laravel docs](/docs/laravel) for advanced setup. - Run the [fan-out calculator](/#calculator) if cost is the migration driver. ## Get going Keep the events. Swap the endpoint. Verify staging, then move the same env diff to production. --- title: Migrate from Pusher to Vask (Next.js). Same SDK, New Host. type: migrate source: https://vask.dev/migrate/pusher-to-vask-nextjs --- # Migrate from Pusher to Vask (Next.js). Same SDK, New Host. Vask speaks the [Pusher protocol](/glossary/pusher-protocol), so a Next.js app using `pusher-js` and the `pusher` server package usually migrates by changing environment values and the explicit host. ## Minimal config diff Set the Vask app key as both `PUSHER_APP_ID` and `PUSHER_APP_KEY`. If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ```diff - PUSHER_APP_ID=123456 - PUSHER_APP_KEY=abc123 - PUSHER_APP_SECRET=xyz789 - PUSHER_APP_CLUSTER=us3 - NEXT_PUBLIC_PUSHER_APP_KEY=abc123 + PUSHER_APP_ID=your_vask_key + PUSHER_APP_KEY=your_vask_key + PUSHER_APP_SECRET=your_vask_secret + PUSHER_HOST=wss.vask.dev + PUSHER_APP_CLUSTER=mt1 + NEXT_PUBLIC_PUSHER_APP_KEY=your_vask_key + NEXT_PUBLIC_PUSHER_HOST=wss.vask.dev + NEXT_PUBLIC_PUSHER_APP_CLUSTER=mt1 ``` `NEXT_PUBLIC_*` values ship to the browser. Never expose `PUSHER_APP_SECRET` through `NEXT_PUBLIC_*`, `next.config.js`, or rendered props. ## Rollback Restore the old Pusher environment values, remove or override `PUSHER_HOST`, redeploy the Next.js app, and hard refresh a browser session. No SDK downgrade is required. ## What changes / what stays - Changes: host, key, secret, public client host, and any shared server Pusher instance. - Stays: `pusher-js`, `channel.bind`, `trigger`, channel names, event names, auth endpoint shape, and presence callbacks. ## Client subscribe Use this in a Client Component or a browser-only shared module: ```ts process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, { cluster: process.env.NEXT_PUBLIC_PUSHER_APP_CLUSTER || 'mt1', wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST || 'wss.vask.dev', wsPort: 443, wssPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], authEndpoint: '/api/pusher/auth', }, ); ``` For App Router, import this only from files marked `'use client'` or from modules used exclusively by Client Components. For Pages Router, the same module can be imported from page components. ## Server publish Use one server-side Pusher instance for Route Handlers, API Routes, jobs, and auth: ```ts appId: process.env.PUSHER_APP_ID!, key: process.env.PUSHER_APP_KEY!, secret: process.env.PUSHER_APP_SECRET!, cluster: process.env.PUSHER_APP_CLUSTER || 'mt1', host: process.env.PUSHER_HOST || 'wss.vask.dev', port: '443', useTLS: true, }); ``` Pages Router uses this from `pages/api/*.ts`. App Router uses it from `app/api/**/route.ts`. ## Private and presence channels The auth endpoint keeps the same signature: ```ts // app/api/pusher/auth/route.ts const formData = await request.formData(); const socketId = String(formData.get('socket_id')); const channelName = String(formData.get('channel_name')); return NextResponse.json( pusherServer.authorizeChannel(socketId, channelName), ); } ``` For Pages Router, keep the same `authorizeChannel(req.body.socket_id, req.body.channel_name)` call inside the API Route. ## Verify 1. Deploy or restart Next.js with the new environment. 2. Hard refresh and confirm the browser opens a WebSocket to `wss.vask.dev`. 3. Trigger from a Pages API Route or App Router Route Handler. 4. Test one public channel and one private or presence channel. ## Gotchas - **Edge Runtime.** If a Route Handler exports `runtime = 'edge'`, avoid the `pusher` npm package there. Use a Node.js route or the REST API with `fetch`. - **App Router imports.** Do not import the browser Pusher client into Server Components. Keep client and server Pusher modules separate. - **Public vs server env.** The key and host can be public; the secret cannot. Audit `NEXT_PUBLIC_*` and any config exposed through `next.config.js`. - **Multiple instances.** Grep for `new Pusher(` and update every browser and server instance, not just the shared module you expect to be used. ## Where to go next - Run the [fan-out calculator](/#calculator) against your workload. - Read the [Pusher vs Vask comparison](/compare/pusher-vs-vask) for pricing and protocol detail. - Check the [Next.js guide](/learn/websockets-in-nextjs) for advanced setup. ## Get going Vask keeps the Next.js migration at the config layer: same browser SDK, same server package, same auth endpoint, new host. --- title: Migrate from Pusher to Vask (Rails). Drop-in for pusher-http-ruby. type: migrate source: https://vask.dev/migrate/pusher-to-vask-rails --- # Migrate from Pusher to Vask (Rails). Drop-in for pusher-http-ruby. Vask speaks the [Pusher protocol](/glossary/pusher-protocol), so a Rails app using `pusher-http-ruby` and `pusher-js` usually migrates by changing credentials, host, and cached assets. ## Minimal config diff Set the Vask app key as both `PUSHER_APP_ID` and `PUSHER_APP_KEY`. If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ```diff - PUSHER_APP_ID=123456 - PUSHER_APP_KEY=abc123 - PUSHER_APP_SECRET=xyz789 - PUSHER_APP_CLUSTER=us2 + PUSHER_APP_ID=your_vask_key + PUSHER_APP_KEY=your_vask_key + PUSHER_APP_SECRET=your_vask_secret + PUSHER_HOST=wss.vask.dev + PUSHER_PORT=443 + PUSHER_SCHEME=https + PUSHER_APP_CLUSTER=mt1 ``` Then make sure the Rails initializer uses the explicit host: ```ruby require 'pusher' Pusher.app_id = ENV['PUSHER_APP_ID'] Pusher.key = ENV['PUSHER_APP_KEY'] Pusher.secret = ENV['PUSHER_APP_SECRET'] Pusher.host = ENV.fetch('PUSHER_HOST', 'wss.vask.dev') Pusher.port = ENV.fetch('PUSHER_PORT', 443).to_i Pusher.scheme = ENV.fetch('PUSHER_SCHEME', 'https') Pusher.cluster = ENV.fetch('PUSHER_APP_CLUSTER', 'mt1') Pusher.encrypted = true ``` ## Rollback Put the old Pusher values back in credentials or ENV, remove or override `PUSHER_HOST`, clear asset caches with your normal Rails workflow, and redeploy. Because the gem and client SDK stay the same, rollback is another credential swap. ## What changes / what stays - Changes: host, key, secret, optional compatibility cluster, and any compiled JavaScript that embeds those values. - Stays: `Pusher.trigger`, channel names, event names, `pusher-js`, private/presence auth signatures, and any ActionCable adapter that reads the same Pusher config. ## Server publish Keep your existing publish calls: ```ruby Pusher.trigger('orders', 'order.created', { id: order.id }) ``` If you publish through an ActionCable Pusher adapter, update the adapter credentials instead of adding a second publisher. If you use ActionCable with the default Redis adapter and no Pusher-compatible SDK, this guide is not a required migration; that stack can keep running as-is. ## Client subscribe Wherever Rails loads `pusher-js` (importmap, jsbundling-rails, Sprockets, or CDN), point the browser at Vask: ```js const pusher = new Pusher(window.VASK_PUSHER.key, { cluster: window.VASK_PUSHER.cluster || 'mt1', wsHost: window.VASK_PUSHER.host || 'wss.vask.dev', wsPort: 443, wssPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], authEndpoint: '/pusher/auth', auth: { headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]') ?.content, }, }, }); ``` Do not assume `process.env` exists in the browser unless your Rails bundler replaces it. Template the public key and host into the page or expose them through your bundler's public-env pattern. ## Private and presence channels The auth route stays the same: ```ruby post '/pusher/auth', to: 'pusher#auth' ``` ```ruby class PusherController < ApplicationController def auth return head :forbidden unless current_user render json: Pusher.authenticate( params[:channel_name], params[:socket_id], user_id: current_user.id.to_s, user_info: { name: current_user.name } ) end end ``` Rails 403s usually mean the CSRF header is missing, the session did not populate `current_user`, or the secret still contains the old Pusher value. ## Verify 1. Deploy or restart Rails with the new credentials. 2. Hard refresh the browser and confirm a WebSocket connects to `wss.vask.dev`. 3. Trigger a `Pusher.trigger` call from a controller, job, or `bin/rails console`. 4. Subscribe to one public channel and one private or presence channel before cutting production traffic. ## Gotchas - **Asset cache still has the old host.** Clear `tmp/cache`, expire CDN assets if needed, and run your normal asset pipeline so the browser receives the new `wsHost`. - **Cluster expectations.** Vask routes by `PUSHER_HOST`, not cluster. Keep `mt1` only for SDK compatibility. - **ActionCable paths can coexist.** Leaving `mount ActionCable.server => '/cable'` in place is fine while pusher-js connects to Vask. Remove it later only if no clients use it. ## Where to go next - Run the [Pusher fan-out calculator](/#calculator) against your workload. - Read the [Pusher vs Vask comparison](/compare/pusher-vs-vask) for pricing and protocol detail. - Check the [Rails guide](/learn/websockets-in-rails) for advanced setup. - If a Laravel service shares the same events, use the [Laravel migration guide](/migrate/pusher-to-vask-laravel). ## Get going Vask keeps the Rails migration at the credential layer: same gem, same client, same auth route, new host. --- title: Migrate from Soketi to Vask (Laravel). Drop-in host swap. type: migrate source: https://vask.dev/migrate/soketi-to-vask-laravel --- # Migrate from Soketi to Vask (Laravel). Drop-in host swap. Soketi and Vask both speak the [Pusher protocol](/glossary/pusher-protocol). A Laravel app using the `pusher` broadcast connection keeps its events, Echo subscriptions, and channel auth logic; only the endpoint and credentials move. ## Minimal cutover Save the current Soketi values first. Then update the env block. If your app still uses `BROADCAST_DRIVER`, keep that name and set it to `pusher`. ```diff BROADCAST_CONNECTION=pusher -PUSHER_APP_ID=app-id -PUSHER_APP_KEY=app-key -PUSHER_APP_SECRET=app-secret -PUSHER_HOST=your-soketi-server.example.com -PUSHER_PORT=6001 -PUSHER_SCHEME=http +PUSHER_APP_ID=your_vask_key +PUSHER_APP_KEY=your_vask_key +PUSHER_APP_SECRET=your_vask_secret +PUSHER_HOST=wss.vask.dev +PUSHER_PORT=443 +PUSHER_SCHEME=https PUSHER_APP_CLUSTER=mt1 VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" ``` If your SDK asks for an app id, use the Vask app key. Vask does not issue a separate customer-facing app id. ## Rollback Point `.env` back at your Soketi server, redeploy or rebuild the client bundle if `VITE_PUSHER_*` values are embedded, and clear config cache if your deploy caches Laravel config. Keep the Soketi process or service online until verification is complete. ## Laravel package shortcut ```bash composer require vask/laravel php artisan vask:install ``` The installer writes the same Pusher-compatible env values and runs `php artisan vask:doctor`. Source: [github.com/vask-dev/laravel](https://github.com/vask-dev/laravel) · [packagist](https://packagist.org/packages/vask/laravel). ## Server publish Confirm `config/broadcasting.php` reads host, port, and scheme from env: ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST'), 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], ], ``` Replace any hardcoded Soketi hostname or `6001` port in this file with env reads. ## Client subscribe Echo should also read host, port, and scheme from Vite env: ```js window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], }); ``` ## Private and presence channels Keep `routes/channels.php`, `/broadcasting/auth`, and the `private-` / `presence-` channel names. Only the service signing the compatible auth response changes. ## Verify 1. Deploy the env change to staging. 2. Confirm the browser opens a [WebSocket](/glossary/websocket) to `wss.vask.dev`, not your Soketi host. 3. Trigger one Broadcast event and verify the client receives it. 4. Verify one private or presence subscription returns 200 from `/broadcasting/auth`. ## Gotchas - **Port and scheme drift.** `PUSHER_PORT=6001` or `PUSHER_SCHEME=http` left over from Soketi will break the hosted cutover. - **Local Soketi still running.** Keep it intentionally for local dev, but do not let local DNS or env files hide that staging should hit Vask. - **TLS bypass settings.** Remove local-only client options such as `verifyHost: false` when targeting Vask. - **Stale Vite bundle.** The browser may keep the old Soketi host until the client bundle is rebuilt. ## Where to go next - Read the [Soketi vs Vask comparison](/compare/soketi-vs-vask) for architecture and operations context. - Check the [Laravel docs](/docs/laravel) for advanced setup. - Run the [fan-out calculator](/#calculator) if cost is the migration driver. ## Get going Keep Soketi available until staging passes, then move the same env diff to production. --- title: Real-time WebSockets in Django: Channels, Pusher, or Vask type: learn source: https://vask.dev/learn/websockets-in-django --- # Real-time WebSockets in Django: Channels, Pusher, or Vask You have a Django app and you want real-time. A notifications panel that refreshes without a page load. A presence indicator on a shared document. A live feed. The pattern is broadcasts and subscriptions, and the question is which substrate carries them. The Django ecosystem gives you three honest options in 2026. This page names them, explains the trade-offs without spin, and shows the code for the one that uses the Pusher Channels protocol. ## Option 1: Django Channels (ASGI) Django Channels is the WebSocket extension maintained as part of the Django project's extended ecosystem. It replaces Django's WSGI entry point with an ASGI one, adds a `URLRouter` for WebSocket paths, and lets you write consumers that handle connection lifecycle and message handling the same way class-based views handle HTTP requests. Official docs: [Django Channels](https://channels.readthedocs.io/). **What it gives you:** - An open-source, protocol-agnostic WebSocket layer in the Django ecosystem. You own the consumer code, the message format, and the protocol shape. - Full Django ORM access inside consumers. Presence tracking, message history, per-user state: all queryable against your existing models. - No external billing. The cost is the ASGI workers and a Redis (or in-memory) channel layer. - The ability to mix HTTP and WebSocket traffic in a single ASGI app under one deployment unit. **When it is the right call:** - Your project already runs ASGI, or the team is ready to make that move. Daphne, Uvicorn, and Hypercorn all support it; a Django Channels migration is a `settings.py` and `asgi.py` change. - You want full control over the WebSocket protocol and message shape. Custom binary frames, GraphQL subscriptions, custom presence logic: Channels does not prescribe any of it. - You prefer no per-message or per-connection billing and you are comfortable operating Redis (or the in-memory layer for low-traffic use). - Local development for any Django project, including projects that may run a hosted service in production. **When you'd reach for something else:** - You want to ship multi-region without operating a multi-region ASGI fleet yourself. - You do not want to manage a channel layer backend (Redis) alongside your application. - Your traffic profile has grown to where scaling ASGI workers and the channel layer is a project, not a config knob. If Channels fits, use Channels. The remainder of this page is for teams that want the Pusher Channels protocol on a managed service, either because the ASGI operating model is not the right fit or because the bill on hosted Pusher has grown. ## Option 2: Hosted Pusher Channels (the incumbent) Pusher Channels is the original hosted [Pusher protocol](/glossary/pusher-protocol) service. The SDK ecosystem is mature (pusher-http-python, pusher-js, official SDKs in every major language), the documentation is thorough, and for years it was the default answer for Django apps that wanted real-time without running their own WebSocket server. **What it gives you:** - A fully managed hosted service. Point `pusher-http-python` at the Pusher cluster you chose at app-creation time and it works. - [Private channels](/glossary/private-channel), [presence channels](/glossary/presence-channel), and [channel auth](/glossary/channel-auth) via a standard auth endpoint in your Django views. - Debug consoles, connection counts, and message throughput visible in the Pusher dashboard without instrumenting anything yourself. **The thing nobody warns you about until you cross a threshold:** The bill scales with the number of subscribers per channel, not with the number of publishes. The category has a name: [fan-out](/glossary/fan-out), and the surcharge is the [fan-out tax](/glossary/fan-out-tax). One publish to a channel with 200 subscribers is 201 billable messages on the standard pricing model. A presence channel with 500 connected users turns a single status [broadcast](/glossary/broadcast) into 501 messages on the bill. High-frequency events like typing indicators compound quickly. This is the priced unit, not a quirk. The model dates from an era when connections were scarce and publishes were rare. Modern apps fan out constantly. **When it is still a reasonable call:** - You are already on Pusher, the bill is within budget, and the integration is stable. Switching costs migration time. - Your traffic pattern is broadcast-light per subscriber, which the per-message model handles without issue. If your fan-out factor is high or growing, the calculator on [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask) shows what the per-fan-out multiplier is doing to your specific bill. ## Option 3: Vask (hosted Pusher protocol on Cloudflare's edge) Vask is a managed Pusher-protocol WebSocket service running on Cloudflare's edge network, billed per broadcast rather than per fan-out copy, with connections terminating at the closest of 330+ edge cities. **What it gives you:** - The [Pusher Channels protocol](/glossary/pusher-protocol) on the wire, kept on purpose. Your `pusher-http-python` server code, your `pusher-js` client, your auth views, your [private](/glossary/private-channel) and [presence](/glossary/presence-channel) channel naming: all unchanged. - Broadcast-priced billing. One publish is one event on the bill regardless of how many subscribers are on the channel. No fan-out multiplier. No presence-channel surcharge. - Connections that terminate on Cloudflare's edge network. - Drop-in compatibility. If you are already on a Pusher-protocol service, the cutover is a credentials change in your environment config, not a code change. **When it is the right call:** - You are on hosted Pusher, the bill is dominated by fan-out, and the math has stopped working. - You want multi-region edge presence without operating a multi-region fleet. - You want hosted Pusher protocol without running ASGI workers, a channel layer, and Redis. **When it is not the right call:** - Channels fits, and the answer is Channels. (Worth repeating.) - You are below the free tier on your current service and the bill is zero. Switch when the bill appears, not before. - Your real-time feature does not fit the Pusher Channels protocol (WebRTC media, MQTT-shaped IoT messaging, a proprietary frame format). If you are migrating off hosted Pusher specifically, the step-by-step Django recipe is at [`/migrate/pusher-to-vask-django`](/migrate/pusher-to-vask-django). The head-to-head comparison with calculator is at [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask). ## What the Django code actually looks like Options 2 and 3 share the same wire protocol, so the application code is identical between them. Only the credentials change. ### Server-side client singleton `myproject/pusher_client.py`: ```python from django.conf import settings pusher_client = pusher.Pusher( app_id=settings.PUSHER_APP_ID, key=settings.PUSHER_APP_KEY, secret=settings.PUSHER_APP_SECRET, host=settings.PUSHER_HOST, ssl=True, ) ``` Import this singleton in views or Celery tasks rather than instantiating a new client per request. ### Publishing from a view or task `myapp/tasks.py`: ```python from myproject.pusher_client import pusher_client def notify_order_shipped(order_id: int, user_id: int) -> None: pusher_client.trigger( f"private-orders.{user_id}", "order.shipped", {"order_id": order_id}, ) ``` `pusher_client.trigger` sends the event. Same call, same result, against Pusher or Vask. ### Channel auth view `myapp/views.py`: ```python from django.http import HttpResponse, HttpResponseForbidden from django.views.decorators.http import require_POST from myproject.pusher_client import pusher_client @require_POST def pusher_auth(request): if not request.user.is_authenticated: return HttpResponseForbidden() channel_name = request.POST.get("channel_name", "") socket_id = request.POST.get("socket_id", "") # For presence channels, return user info as the auth payload. if channel_name.startswith("presence-"): auth = pusher_client.authenticate( channel=channel_name, socket_id=socket_id, custom_data={"user_id": request.user.pk, "user_info": {"name": request.user.get_full_name()}}, ) else: auth = pusher_client.authenticate(channel=channel_name, socket_id=socket_id) return HttpResponse(json.dumps(auth), content_type="application/json") ``` Wire it up in `urls.py`: ```python from myapp.views import pusher_auth urlpatterns = [ path("pusher/auth/", pusher_auth, name="pusher_auth"), ] ``` The view is decorated with `@require_POST`. Django's CSRF middleware applies by default. The client must forward the CSRF token so the auth request passes validation. **CSRF trade-off.** Adding `@csrf_exempt` removes the CSRF check and is simpler to configure. The risk is that the auth endpoint becomes callable cross-origin without the browser's CSRF protection. The safer approach is forwarding the token: two extra lines in the client. Prefer forwarding the token unless the project already disables CSRF middleware globally. ### Client setup (pusher-js) Works with Vite, Webpack, `django-vite`, or a CDN script tag. The `pusher-js` package does not depend on the bundler, but the way you expose browser-safe config does. In a Django template, render a small public config object rather than reading `process.env` in the browser. ```js const config = window.VASK_PUSHER; const pusher = new Pusher(config.key, { cluster: config.cluster || 'mt1', wsHost: config.host || 'wss.vask.dev', wssPort: 443, forceTLS: true, authEndpoint: '/pusher/auth/', auth: { headers: { // Forward the Django CSRF token on the auth POST. 'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] ?? '', }, }, }); const channel = pusher.subscribe('private-orders.' + userId); channel.bind('order.shipped', (data) => { console.log('Order shipped:', data.order_id); }); ``` The `X-CSRFToken` header satisfies Django's CSRF middleware without `@csrf_exempt`. ## Picking between the three: a flowchart ``` Do you have a Django app? | YES | Is Channels over ASGI a viable operating model? (team owns ASGI workers + Redis channel layer) | ┌────────────┴────────────┐ YES NO | | CHANNELS. Are you OK with the Done. per-fan-out billing model on hosted Pusher? | ┌───────────┴───────────┐ YES NO | | PUSHER. VASK. Done. Done. ``` Three honest answers. Options 2 and 3 share the same client code and server SDK. The decision is operating model and bill shape. ## When NOT to switch from Channels If you are running Django Channels in ASGI, the concurrency fits, and the team is comfortable with the Redis channel layer, stay on Channels. Vask is not the "next level" from Channels. It is a different answer for a different shape of project. The cases where moving to a hosted Pusher-protocol service actually makes sense: - You have outgrown a single-region operating model and your users are in multiple continents. - You no longer want to operate ASGI workers, a Redis channel layer, and the supervision around them. - Your concurrency is high enough that scaling the channel layer is a project, not a config change. None of those are about Channels being a bad tool. They are about the project changing shape. If your project has not changed shape, do not migrate. ## Get going If you want Channels, the [Django Channels documentation](https://channels.readthedocs.io/) is the canonical reference. If you want hosted Pusher protocol on Cloudflare's edge with broadcast-priced billing, the recipe below gets you there. --- title: Real-time WebSockets in Laravel: Reverb, Pusher, or Vask type: learn source: https://vask.dev/learn/websockets-in-laravel --- # Real-time WebSockets in Laravel: Reverb, Pusher, or Vask You have a Laravel app and you want real-time. A notifications drawer that updates without a page refresh. A presence indicator on a document. A live dashboard. A typing indicator in a chat surface. The pattern fits broadcasts and channels, and the question is which substrate carries them. The good news in 2026 is that the Laravel ecosystem gives you three honest options. Reverb is the official Laravel path. Pusher and Vask use the Pusher Channels protocol and the Pusher-compatible SDK ecosystem. The decision is mostly operating model and bill shape. This page is the honest answer. Read the three options, find the one that matches the shape of your project, and stop there. ## Option 1: Laravel Reverb (first-party, in-process) Laravel Reverb is the WebSocket server shipped by the Laravel team. Current Laravel docs install broadcasting with `php artisan install:broadcasting`, configure Reverb-specific environment variables, and run Reverb as a long-lived process alongside your application. Use the official Reverb docs as the source of truth for new Laravel scaffolds. Official docs: [Laravel Broadcasting](https://laravel.com/docs/broadcasting) and [Laravel Reverb](https://laravel.com/docs/reverb). **What it gives you:** - A first-party, free, open-source WebSocket server maintained by the people who maintain the framework. - Pusher Channels protocol on the wire, so every existing laravel-echo example on the internet applies unchanged. - Zero per-message billing. The cost is your server, which you are already paying for. - The full power of being in-process with your application: presence channels, private channels, channel auth, all backed by your existing User model and your existing auth pipeline. **When it is the right call:** - Low to medium traffic, single-region apps. If most of your users are inside one continent and a single VPS or small cluster can hold the concurrency, the latency story is fine. - Teams comfortable running a long-lived process and the supervision around it: a supervisor or systemd unit, a restart strategy, a deploy story that does not drop active WebSocket connections. - Projects where the real-time feature is one part of a larger Laravel app, not the product itself, and you want the smaller stack with fewer vendors. - Local development for any Laravel app, including apps that run a hosted service in production. Reverb on your laptop, hosted Pusher protocol in production, same wire format. **When you'd reach for something else:** - You want to ship multi-region without operating a multi-region WebSocket fleet yourself. - You do not want to be on call for the WebSocket process. (Same code, different on-call shape.) - Your concurrency or broadcast volume has grown to where a single in-process server is becoming a scaling project, not a deploy step. If Reverb fits, use Reverb. The remainder of this page is for the cases where it does not, or for teams already on a hosted Pusher service evaluating where to go next. ## Option 2: Hosted Pusher Channels (the incumbent) Pusher Channels is the original hosted Pusher-protocol service. It is the reason the Pusher protocol exists in the first place. The SDKs are mature, the documentation is good, and for years it was the obvious choice for a Laravel app that did not want to run its own WebSocket server. **What it gives you:** - A fully managed, hosted Pusher-protocol service. You point `PUSHER_HOST` at the Pusher cluster you pick at app creation time and it works. - A mature ecosystem: pusher-js, laravel-echo, pusher-http-php, debug consoles, official SDKs in every working language. - Presence channels, private channels, channel auth all work the standard way. **The thing nobody warns you about until you cross a threshold:** The bill scales with the number of subscribers on each channel, not with the number of broadcasts you publish. The category has a name for this workload (fan-out) and the surcharge attached to it is the fan-out tax. One broadcast to a channel with 100 subscribers counts as 101 billable messages on the standard pricing model. Run a presence channel with 1,000 connected users and a single broadcast becomes 1,001 billable messages. Ship a typing indicator over a busy room and the math compounds quickly. This is not a quirk. It is the priced unit of the product. The model dates from a previous era of real-time when broadcasts were rare and connections were the scarce resource. Modern apps fan out constantly; the model has not caught up. **When it is still a reasonable call:** - You are already on Pusher, the bill is fine for your traffic shape, and the integration is tuned. Switching costs you migration time. If the math does not justify it, do not switch. - Your traffic profile happens to be broadcast-light per subscriber (very large channels, very few publishes), which the per-message model handles fine. If your fan-out factor is high or growing, the calculator on [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask) shows what the per-fan-out multiplier is doing to your specific bill. ## Option 3: Vask (hosted Pusher protocol on Cloudflare's edge) Vask is the option that did not exist a few years ago and now does. A managed Pusher-protocol WebSocket service running on Cloudflare's edge network, billed per broadcast (not per fan-out copy), with connections terminating at the closest of 330+ edge cities. **What it gives you:** - The Pusher Channels protocol on the wire, kept on purpose. Your laravel-echo client, your pusher-php-server backend, your Broadcast events, your channel auth callback, your private and presence channel naming: all unchanged. - Cloudflare's edge as the substrate. Connections terminate on Cloudflare's edge network, not a single home region. - Broadcast-priced billing. One broadcast to a channel is one broadcast on the bill, regardless of how many subscribers are on it. No per-fan-out multiplier. No presence-channel surcharge. No surprise line item when the typing indicator ships. - Drop-in compatibility. If you are already on a Pusher-protocol service today, the cutover is a host and credential change in `.env` plus a restart. **When it is the right call:** - You are on hosted Pusher today, your bill is dominated by fan-out, and the math has stopped working. The receipt is mechanical: same protocol, broadcast-priced billing. - You want multi-region edge presence without operating a multi-region WebSocket fleet yourself. - You want hosted Pusher protocol without running a long-lived process and the supervision around it. **When it is not the right call:** - Reverb fits, and the answer is Reverb. (Repeated because it matters.) - You are below the free tier on your current service and the bill is zero. Switch when the bill shows up, not before. - Your real-time feature does not fit the Pusher Channels protocol (WebRTC media routing, MQTT-shaped IoT messaging, a proprietary wire format). The protocol-compatibility wedge is not a wedge for you. If you are migrating off hosted Pusher specifically, the step-by-step Laravel recipe is at [`/migrate/pusher-to-vask-laravel`](/migrate/pusher-to-vask-laravel). The head-to-head comparison with calculator is at [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask). ## What the Laravel code actually looks like For Pusher and Vask, the application code is the same because both speak the Pusher Channels protocol. Reverb is the Laravel-first path and current Laravel scaffolds use Reverb-specific env names, so do not blindly copy a Pusher config into a Reverb app. ### Client setup (laravel-echo) For Vask, the Echo client uses Pusher-compatible settings: ```js window.Pusher = Pusher; window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1', wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], }); ``` For Reverb, follow Laravel's generated Echo config instead of renaming these variables by hand. ### Server config (`config/broadcasting.php`) ```php 'connections' => [ 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST'), 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], ], ], ``` For Vask, the important piece is the explicit host. Vask gives you a key and secret; if the Laravel/Pusher config requires `PUSHER_APP_ID`, use the Vask key for that field. Vask does not route by cluster. ### Broadcast event `app/Events/OrderShipped.php`: ```php order->user_id)]; } public function broadcastAs(): string { return 'order.shipped'; } } ``` `broadcast(new OrderShipped($order))` fires it. Same code, all three options. ### Presence channel with auth callback `routes/channels.php`: ```php can('view', $document)) { return false; } return [ 'id' => $user->id, 'name' => $user->name, 'avatar_url' => $user->avatar_url, ]; }); ``` The closure returns the array shape that becomes the presence member payload. The `/broadcasting/auth` route handles the signed-request validation. Same code, all three options. (For the term itself, see [`/glossary/channel-auth`](/glossary/channel-auth) and [`/glossary/presence-channel`](/glossary/presence-channel).) ### Client subscription `resources/js/document.js`: ```js window.Echo.join(`document.${documentId}`) .here((members) => { // initial list of users present in the document }) .joining((member) => { // a user just joined }) .leaving((member) => { // a user just left }) .listen('.cursor.moved', (event) => { // a presence member broadcasted their cursor }); ``` `.here()`, `.joining()`, `.leaving()` are presence-channel lifecycle events. `.listen()` subscribes to a [broadcast](/glossary/broadcast) on the channel. Same code, all three options. ## Picking between the three: a flowchart ``` Do you have a Laravel app? | YES | Is Reverb in-process a viable operating model? (low-medium traffic, single region, ops capacity) | ┌─────────────┴─────────────┐ YES NO | | REVERB. Are you OK with the Done. per-fan-out billing model on hosted Pusher? | ┌───────────┴───────────┐ YES NO | | PUSHER. VASK. Done. (or another Done. hosted Pusher-protocol service) ``` Three honest answers, all of which use the same laravel-echo client and the same Broadcast event surface. The decision is operating model and bill shape, not application code. ## When NOT to switch from Reverb This is worth saying out loud because the temptation when reading a vendor page is to assume the vendor's option is always the right one. It is not. If you are running Reverb in-process, the traffic fits, and the ops are fine, **stay on Reverb**. We will not pretend otherwise. Vask is the hosted Pusher-protocol option for teams that want the protocol without operating their own WebSocket server, or that need edge delivery beyond what an in-process server can give them. It is not the "next step" from Reverb. It is a different shape of answer for a different shape of project. The cases where moving off Reverb actually makes sense: - You have outgrown a single-region operating model and your users are in multiple continents. - You no longer want to run a long-lived WebSocket process alongside your app and the supervision around it. - Your concurrency is high enough that horizontal scaling of Reverb is becoming a project rather than a configuration knob. None of those are about Reverb being a bad fit. They are about the project changing shape. If your project has not changed shape, do not migrate. ## Get going If you want Reverb, the [Laravel Reverb documentation](https://laravel.com/docs/reverb) is the canonical reference. If you want hosted Pusher protocol on Cloudflare's edge with broadcast-priced billing, the recipe below gets you there. --- title: Real-time WebSockets in Next.js: Roll-your-own, Cloudflare, or Vask type: learn source: https://vask.dev/learn/websockets-in-nextjs --- # Real-time WebSockets in Next.js: Roll-your-own, Cloudflare, or Vask You have a Next.js app and you want real-time. A live notification counter. A collaborative presence indicator. A live dashboard that does not need a page refresh. A typing indicator in a chat surface. Here is the first thing to know: Next.js does not ship a first-party WebSocket server. Unlike Laravel (which ships Reverb) or Rails (which ships ActionCable), Next.js is optimized for request/response. API routes and route handlers terminate after sending a response. Persistent WebSocket connections are out of scope for the framework by design. This is not a criticism. It is a constraint that shapes your options, and the options are real and well-understood. This page walks through the three that matter in 2026. ## Option 1: Roll-your-own WebSocket server The first option is to run a WebSocket server yourself, separate from Next.js. Common approaches: a Node.js process using the `ws` package, a custom server that replaces Next.js's built-in HTTP server, or a standalone microservice alongside your Next.js deployment. **What it gives you:** - Full control. Any wire protocol, any auth model, any persistence layer. - No per-message billing. The cost is your server. - Works on any hosting platform that lets you run a persistent Node.js process. **The real constraints:** Running a persistent WebSocket server is an ops commitment. You need process supervision, a restart strategy, and a deploy story that does not drop active connections. On serverless platforms (Vercel, Netlify), you cannot run a long-lived WebSocket process at all: functions terminate after the response. You would need to deploy the WebSocket server separately on a platform that supports persistent processes. On Vercel, do not plan to host a durable broadcast WebSocket server inside Functions. Use a separate service for the persistent socket layer, then publish to it from Next.js route handlers or API routes. Official docs: [Vercel's WebSocket guidance](https://vercel.com/guides/do-vercel-serverless-functions-support-websocket-connections). **When it is the right call:** - You have a custom protocol requirement that no hosted service supports. - You are already running your own infrastructure and the ops burden is already accounted for. - Your team has the capacity to own the WebSocket process long-term. If none of those apply, the hosted options below are the more reliable path for most Next.js projects. ## Option 2: Cloudflare Durable Objects / Workers Cloudflare's Durable Objects give you stateful, globally distributed WebSocket support without running your own server. A Durable Object is a single-threaded actor with persistent storage; each one can hold many WebSocket connections and fan messages out to all of them. **What it gives you:** - Stateful WebSocket rooms at the edge, with the persistence and consistency guarantees Durable Objects provide. - Cloudflare's 330+ edge cities as the substrate. Connections terminate at the closest city to the user. - No long-lived process to supervise. The runtime manages lifecycle. **The real constraints:** The programming model is different. Durable Objects are not a drop-in for the Pusher Channels pattern; you are building the routing and auth layer yourself, in Workers code. There is no pusher-js client that speaks the Durable Objects protocol natively. You are building the real-time infrastructure, not consuming it. The trade-off is flexibility versus integration cost. If you want the Pusher Channels protocol (channel subscriptions, private channels, presence channels, channel auth) without building it from scratch, the Durable Objects path requires significant glue code. The full picture for the Cloudflare architecture is at [`/alternatives/cloudflare-websockets`](/alternatives/cloudflare-websockets). **When it is the right call:** - You want edge-native WebSocket infrastructure with full control over the room model. - You are building on the Cloudflare stack already and want to avoid a separate vendor. - Your use case does not map to the Pusher Channels protocol and you need custom routing. ## Option 3: Vask (hosted Pusher protocol on Cloudflare's edge) Vask is the option that collapses the integration cost of Option 2 while keeping the edge delivery. A managed Pusher-protocol WebSocket service running on Cloudflare's edge, billed per broadcast (not per delivered message), with connections terminating at the closest of 330+ edge cities. **What it gives you:** - The [Pusher Channels protocol](/glossary/pusher-protocol) on the wire, kept on purpose. Your pusher-js client, your channel subscriptions, your private and presence channel naming, your channel auth endpoint: all standard, all documented, all done. - Cloudflare's edge as the substrate. Same edge network as Option 2, without the Durable Objects glue code. - Broadcast-priced billing. One broadcast to a channel is one broadcast on the bill, regardless of how many subscribers are on it. No [fan-out tax](/glossary/fan-out-tax). No presence-channel surcharge. - Drop-in compatibility with any existing Pusher-protocol integration. If you are already on Pusher Channels, the cutover is a credentials change in `.env.local`, not a code change. **When it is the right call:** - You want channels, private channels, and presence in a Next.js app without building the infrastructure layer yourself. - You are on hosted Pusher today, the bill is growing with [fan-out](/glossary/fan-out), and you want the same protocol with broadcast-priced billing. - You want multi-region edge presence without an ops commitment. If you are migrating from hosted Pusher specifically, the step-by-step Next.js recipe is at [`/migrate/pusher-to-vask-nextjs`](/migrate/pusher-to-vask-nextjs). The head-to-head comparison with calculator is at [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask). ## What the Next.js code actually looks like The Pusher-protocol integration is the same regardless of whether the service is Pusher or Vask. The differences are credentials in `.env.local`. ### Environment variables `.env.local`: ```bash NEXT_PUBLIC_PUSHER_APP_KEY=your_vask_key NEXT_PUBLIC_PUSHER_HOST=wss.vask.dev NEXT_PUBLIC_PUSHER_PORT=443 NEXT_PUBLIC_PUSHER_APP_CLUSTER=mt1 # Server-side only (no NEXT_PUBLIC_ prefix) PUSHER_APP_ID=your_vask_key PUSHER_APP_KEY=your_vask_key PUSHER_APP_SECRET=your_vask_secret PUSHER_HOST=wss.vask.dev PUSHER_APP_CLUSTER=mt1 ``` `NEXT_PUBLIC_*` variables are inlined at build time and safe to expose to the browser. The secret must not have the `NEXT_PUBLIC_` prefix. Vask gives you a key and secret; if an SDK requires `appId`, use the Vask key for that field. `mt1` is only a compatibility placeholder for SDKs that expect a cluster value. ### Singleton client (Pages Router and App Router) Hot-reload re-creates modules on every save. Without a singleton guard, each save creates a new Pusher connection, which exhausts your connection limit quickly in development. `lib/pusher-client.ts`: ```ts let pusherInstance: Pusher | null = null; if (pusherInstance) { return pusherInstance; } pusherInstance = new Pusher(process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, { wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST, wsPort: Number(process.env.NEXT_PUBLIC_PUSHER_PORT ?? 443), forceTLS: true, enabledTransports: ['ws', 'wss'], cluster: process.env.NEXT_PUBLIC_PUSHER_APP_CLUSTER ?? 'mt1', channelAuthorization: { endpoint: '/api/pusher/auth', transport: 'ajax', }, }); return pusherInstance; } ``` The module-level `pusherInstance` variable persists across hot-reloads in development and across component mounts/unmounts in production. ### Client subscription (App Router with `'use client'`) `app/components/presence-indicator.tsx`: ```tsx 'use client'; type Member = { id: string; name: string }; const [members, setMembers] = useState<{ id: string; name: string }[]>([]); useEffect(() => { const pusher = getPusherClient(); const channel = pusher.subscribe(`presence-document.${documentId}`); channel.bind( 'pusher:subscription_succeeded', (data: { members: Record }) => { setMembers(Object.values(data.members)); }, ); channel.bind( 'pusher:member_added', (member: { id: string; info: Member }) => { setMembers((prev) => [...prev, member.info]); }, ); channel.bind('pusher:member_removed', (member: { id: string }) => { setMembers((prev) => prev.filter((m) => m.id !== member.id)); }); return () => { pusher.unsubscribe(`presence-document.${documentId}`); }; }, [documentId]); return
{members.length} online
; } ``` The `'use client'` directive is required. Server components cannot hold WebSocket connections; the Pusher client must initialize in a client component. The singleton from `getPusherClient()` prevents a new connection on every render. ### Channel auth endpoint (App Router route handler) `app/api/pusher/auth/route.ts`: ```ts const pusherServer = new Pusher({ appId: process.env.PUSHER_APP_ID!, key: process.env.PUSHER_APP_KEY!, secret: process.env.PUSHER_APP_SECRET!, host: process.env.PUSHER_HOST!, useTLS: true, }); const session = await auth(); if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await request.text(); const params = new URLSearchParams(body); const socketId = params.get('socket_id')!; const channel = params.get('channel_name')!; const presenceData = { user_id: session.user.id, user_info: { name: session.user.name, }, }; const authResponse = pusherServer.authorizeChannel( socketId, channel, presenceData, ); return NextResponse.json(authResponse); } ``` For Pages Router, the equivalent lives at `pages/api/pusher/auth.ts` and uses `req.body` instead of `request.text()`. The [channel auth](/glossary/channel-auth) logic is the same; only the handler signature differs. ### Publishing a broadcast (App Router route handler) `app/api/events/route.ts`: ```ts const pusherServer = new Pusher({ appId: process.env.PUSHER_APP_ID!, key: process.env.PUSHER_APP_KEY!, secret: process.env.PUSHER_APP_SECRET!, host: process.env.PUSHER_HOST!, useTLS: true, }); const { channel, event, data } = await request.json(); await pusherServer.trigger(channel, event, data); return NextResponse.json({ ok: true }); } ``` The `pusher` package is the official Pusher HTTP library for Node.js. It speaks to any Pusher-protocol service, including Vask. The same `trigger()` call works whether `PUSHER_HOST` points at Pusher's clusters or Vask's edge. ## Picking between the three ``` Do you have a Next.js app? | YES | Do you need the Pusher Channels protocol (channels, presence, channel auth) without building it yourself? | ┌─────────────┴─────────────┐ YES NO | | Is hosted infra acceptable? Cloudflare DOs/Workers | or roll-your-own. ┌─────┴─────┐ YES NO | | VASK. Roll-your-own Done. WebSocket server. ``` Vask is the right answer when you want the Pusher protocol handled for you, on Cloudflare's edge, without operating infrastructure. If you need full control of the WebSocket room model, Cloudflare Durable Objects are the right primitive. If neither, a standalone WebSocket server is the escape hatch. ## Get going The `pusher` and `pusher-js` packages install in under a minute. Credentials from your Vask dashboard go in `.env.local`, the singleton client prevents hot-reload re-init, and the channel auth endpoint wires presence in a route handler. Most Next.js teams ship the integration in a few hours. --- title: Real-time WebSockets in Rails: ActionCable, Pusher, or Vask type: learn source: https://vask.dev/learn/websockets-in-rails --- # Real-time WebSockets in Rails: ActionCable, Pusher, or Vask You have a Rails app and you want real-time. A notifications drawer that updates without a page refresh. A presence indicator on a document. A live dashboard. A typing indicator in a chat surface. The pattern fits broadcasts and channels, and the question is which substrate carries them. The Rails ecosystem gives you three honest options in 2026. ActionCable is the first-party answer, built into the framework since Rails 5. Pusher and Vask are hosted services that speak the Pusher Channels protocol, a different wire format from ActionCable but one with its own set of advantages around edge delivery and bill shape. The decision is about operating model and bill shape, not about rewriting your app. ## Option 1: ActionCable (first-party, in-process) ActionCable is the WebSocket server built into Rails. It runs inside your Puma process, integrates with your existing session and Devise or Warden auth pipeline, and uses Redis or PostgreSQL as a broadcast backplane when you need multiple processes or hosts. No extra gem, no extra service beyond the backplane adapter you may already run. **What it gives you:** - A first-party, free, open-source WebSocket layer maintained by the Rails core team. - Deep session integration: `current_user` available inside channel callbacks from day one. - A backplane adapter model (Redis, PostgreSQL, or async in development) that scales horizontally when you add Puma processes. - Hotwire Turbo Streams as a first-class broadcast target if you are on a Turbo-shaped Rails app. **When it is the right call:** - Low to medium traffic, single-region apps where Puma can hold the WebSocket concurrency without a dedicated process. - Teams that want to stay in Ruby end-to-end and avoid adding the JavaScript SDK overhead of pusher-js. - Projects on Hotwire where Turbo Broadcasts feed UI fragments directly, without a separate JS subscription layer. - Local development for any Rails app, regardless of what runs in production. **When you'd reach for something else:** - You want connections to terminate at the closest edge city rather than your origin server. - The Redis or PostgreSQL backplane adapter is becoming an operational concern at your traffic level. - Your broadcast volume has grown such that the backplane is a bottleneck and horizontal scaling has become a project. If ActionCable fits, use ActionCable. The rest of this page is for the cases where it does not, or for teams already on hosted Pusher evaluating where to go next. ## Option 2: Hosted Pusher Channels (the incumbent) Pusher Channels is the original hosted Pusher-protocol service. The pusher-http-ruby gem handles server-side triggers, pusher-js handles the client, and the integration is well-documented. For years it was the obvious choice for a Rails app that wanted broadcast delivery without running its own WebSocket server. **What it gives you:** - A fully managed, hosted Pusher-protocol service. Configure credentials, point pusher-js at the right cluster, and it works. - A mature SDK ecosystem: pusher-http-ruby on the server, pusher-js on the client, presence channels, private channels, channel auth. - No backplane adapter to operate. Pusher handles delivery to all connected clients. **The thing nobody warns you about until you cross a threshold:** The bill scales with the number of subscribers on each channel, not with the number of broadcasts you publish. The category has a name for this workload ([fan-out](/glossary/fan-out)) and the surcharge attached to it is the [fan-out tax](/glossary/fan-out-tax). One broadcast to a channel with 100 subscribers counts as 101 billable messages on the standard pricing model. A presence channel with 1,000 connected users makes a single broadcast into 1,001 billable messages. Ship a typing indicator over a busy room and the math compounds quickly. This is not a quirk. It is the priced unit of the product. If your fan-out factor is high or growing, the calculator on [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask) shows what the per-fan-out multiplier is doing to your specific bill. **When it is still a reasonable call:** - You are already on Pusher, the bill is fine for your traffic shape, and switching costs you migration time. If the math does not justify it, do not switch. - Your traffic profile is broadcast-light per subscriber, which the per-message model handles without surprise. ## Option 3: Vask (hosted Pusher protocol on Cloudflare's edge) Vask is the option that did not exist a few years ago and now does. A managed Pusher-protocol WebSocket service running on Cloudflare's edge network, billed per broadcast (not per fan-out copy), with connections terminating at the closest of 330+ edge cities. **What it gives you:** - The [Pusher Channels protocol](/glossary/pusher-protocol) on the wire. Your pusher-http-ruby triggers, your pusher-js subscriptions, your [channel auth](/glossary/channel-auth) endpoint, your [presence channel](/glossary/presence-channel) member payloads: all unchanged. - Cloudflare's edge as the substrate. Connections terminate on Cloudflare's edge network. - Broadcast-priced billing. One broadcast is one [broadcast](/glossary/broadcast) on the bill, regardless of how many subscribers are on the channel. No [fan-out tax](/glossary/fan-out-tax). No presence-channel surcharge. - Drop-in compatibility. If you are already on hosted Pusher, the cutover is a host and credential change in your environment plus a Puma restart. **When it is the right call:** - You are on hosted Pusher today and the bill is dominated by fan-out. Same protocol, broadcast-priced billing. - You want multi-region edge presence without operating a multi-region server fleet. - You want hosted Pusher protocol without a backplane adapter to manage and without holding WebSocket connections inside your Puma processes. **When it is not the right call:** - ActionCable fits, and the answer is ActionCable. (Repeated because it matters.) - You are below the free tier on your current service and the bill is zero. Switch when the bill shows up, not before. - Your real-time feature does not fit the Pusher Channels protocol. If you are migrating off hosted Pusher specifically, the step-by-step Rails recipe is at [`/migrate/pusher-to-vask-rails`](/migrate/pusher-to-vask-rails). The head-to-head comparison with calculator is at [`/compare/pusher-vs-vask`](/compare/pusher-vs-vask). ## What the Rails code actually looks like The Pusher-protocol path (Pusher and Vask) shares the same application code. The differences are in credentials and the initializer. ### Server setup `Gemfile`: ```ruby gem 'pusher' ``` `config/initializers/pusher.rb`: ```ruby require 'pusher' Pusher.app_id = ENV.fetch('PUSHER_APP_ID') Pusher.key = ENV.fetch('PUSHER_APP_KEY') Pusher.secret = ENV.fetch('PUSHER_APP_SECRET') Pusher.cluster = ENV.fetch('PUSHER_APP_CLUSTER', 'mt1') Pusher.host = ENV['PUSHER_HOST'] if ENV['PUSHER_HOST'].present? Pusher.port = ENV['PUSHER_PORT'].to_i if ENV['PUSHER_PORT'].present? ``` Set `PUSHER_HOST` to your Vask endpoint to route through Vask; leave it unset to route through Pusher's cluster. ### Triggering a broadcast From a job or controller action: ```ruby Pusher.trigger('orders', 'order.shipped', { order_id: order.id }) ``` That is one broadcast. One billable unit on Vask, regardless of how many subscribers are on `orders`. ### Client setup (pusher-js) Install via npm (`package.json`) if you are on an esbuild or importmap-with-bundler setup: ```sh npm install pusher-js ``` Then import and configure. If you are using importmap, Sprockets, or a CDN script tag, render the public config from Rails instead of reading `process.env` in the browser. In the example below, `window.VASK_PUSHER` is a template-rendered object with `key`, `host`, and `cluster`. ```js const config = window.VASK_PUSHER; const pusher = new Pusher(config.key, { cluster: config.cluster || 'mt1', wsHost: config.host || 'wss.vask.dev', wsPort: 443, wssPort: 443, forceTLS: true, enabledTransports: ['ws', 'wss'], channelAuthorization: { endpoint: '/pusher/auth', transport: 'ajax', headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content ?? '', }, }, }); ``` The `channelAuthorization.headers` block is the Rails-specific addition. Rails CSRF protection rejects the auth POST without it. ### Channel auth endpoint `config/routes.rb`: ```ruby post '/pusher/auth', to: 'pusher#auth' ``` `app/controllers/pusher_controller.rb`: ```ruby class PusherController < ApplicationController def auth if user_signed_in? response = Pusher.authenticate(params[:channel_name], params[:socket_id], { user_id: current_user.id, user_info: { name: current_user.name }, }) render json: response else render json: { error: 'Forbidden' }, status: :forbidden end end end ``` For a private channel, `user_info` is ignored. For a presence channel, it becomes the member payload visible to other subscribers in `.here()` and `.joining()` callbacks on the client. ### ActionCable Pusher adapter (optional) If you want to keep ActionCable channel conventions on the client and route through Vask, the `actioncable-pusher-adapter` npm package handles the translation layer. Configure it with your Vask credentials and your existing ActionCable channel subscriptions continue to receive messages, while WebSocket connections land on Cloudflare's edge rather than inside Puma. This is an advanced option. If you are starting fresh on the Pusher-protocol path, use pusher-js directly. ## Picking between the three: a flowchart ``` Do you have a Rails app? | YES | Is ActionCable in-process a viable operating model? (low-medium traffic, single region, ops capacity) | ┌─────────────┴─────────────┐ YES NO | | ACTIONCABLE. Are you OK with the Done. per-fan-out billing model on hosted Pusher? | ┌───────────┴───────────┐ YES NO | | PUSHER. VASK. Done. (or another Done. hosted Pusher-protocol service) ``` Three honest answers, all sharing the same pusher-http-ruby server SDK and pusher-js client. The decision is operating model and bill shape, not application code. ## When NOT to switch from ActionCable If you are running ActionCable in-process, the traffic fits, and the ops are fine, stay on ActionCable. Vask is the hosted Pusher-protocol option for teams that want broadcast-priced edge delivery without operating their own WebSocket layer. It is not the "next step" from ActionCable. It is a different shape of answer for a different shape of project. The cases where moving off ActionCable actually makes sense: - You have outgrown a single-region model and your users are in multiple continents. - The Redis or PostgreSQL backplane adapter has become an operational concern at your traffic level. - You no longer want to hold WebSocket connections inside your Puma processes and manage the lifecycle around them. None of those are about ActionCable being a bad fit. They are about the project changing shape. If your project has not changed shape, do not migrate. ## Get going If you want ActionCable, the [Rails ActionCable guides](https://guides.rubyonrails.org/action_cable_overview.html) are the canonical reference. If you want hosted Pusher protocol on Cloudflare's edge with broadcast-priced billing, add `gem 'pusher'` to your Gemfile, configure the initializer, and point your client at Vask. --- title: Broadcast (definition). The unit of work in a real-time channel. type: glossary source: https://vask.dev/glossary/broadcast --- # Broadcast A broadcast is the single message a publisher sends to a real-time channel. The publisher emits one broadcast. The service receives one broadcast. Whatever happens after that (delivery to one subscriber, one thousand, or one million) is [fan-out](/glossary/fan-out), not additional broadcasts. ## Why it matters The broadcast versus delivered-copy distinction is the single most consequential framing question in real-time pricing. The publisher's code does the same work regardless of subscriber count: one event dispatch, one serialized payload, one outbound HTTP request to the service. The service's work scales with subscribers. Pricing models that bill at the broadcast layer charge for the publisher's work. Pricing models that bill at the delivery layer charge for the service's work, multiplied by audience. Modeling a real-time bill correctly starts with deciding which unit the service charges for. A workload with 100 broadcasts per minute against a 10,000-subscriber channel is 100 broadcasts under one model and 1,000,100 messages under the other. The protocol on the wire is identical. ## How it works A broadcast travels along a fixed path. The publisher's application code constructs an event (a name and a payload). A server-side SDK signs an HTTP request to the WebSocket service's REST API or pushes an event frame over an authenticated server-to-server [WebSocket](/glossary/websocket). The service receives the broadcast, looks up the channel's subscriber set, and writes a delivery frame to each connected subscriber. A minimal Laravel broadcast: ```php class OrderShipped implements ShouldBroadcast { public function __construct(public Order $order) {} public function broadcastOn(): Channel { return new PrivateChannel('user-'.$this->order->user_id); } } OrderShipped::dispatch($order); ``` That dispatch produces one broadcast. The service then fans it out to every active subscriber of `private-user-42` over the [Pusher protocol](/glossary/pusher-protocol). For [public channels](/glossary/public-channel) the same pattern applies without an auth route. For [private channels](/glossary/private-channel) the subscriber went through [channel auth](/glossary/channel-auth) before joining; the broadcast itself does not re-check auth. ## Related terms - [Fan-out](/glossary/fan-out) is what happens to a broadcast after the service receives it. - [Fan-out tax](/glossary/fan-out-tax) is the surcharge applied when a service bills delivered copies instead of broadcasts. - [Pusher protocol](/glossary/pusher-protocol) defines the event frame format a broadcast becomes on the wire. ## See also - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the broadcast-versus-message billing comparison. - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for the Laravel broadcasting stack end to end. --- title: Channel auth (definition). The signed handshake for private and presence channels. type: glossary source: https://vask.dev/glossary/channel-auth --- # Channel auth Channel auth is the signed handshake that authorizes a client to subscribe to a [private channel](/glossary/private-channel) or [presence channel](/glossary/presence-channel). The application server inspects the user's session, decides whether the subscription is allowed, and returns an HMAC token. The WebSocket service verifies that token before accepting the subscription frame. ## Why it matters Channel auth is the bridge between the WebSocket service (which knows about connections and channels) and the application server (which knows about users, sessions, and permissions). The service does not need to know what a user is. The application does. Channel auth puts the authorization decision on the side that already has the answer. This matters operationally. A team can change its auth model (rotate session strategy, add MFA, swap identity providers) without touching the WebSocket service. The service only ever sees a signed string. The signature is the contract. It also matters for migrations. Because the channel auth endpoint lives in the application, and the signing algorithm is fixed by the [Pusher protocol](/glossary/pusher-protocol), the same auth route serves any protocol-compatible host. Migrations swap host and credentials; the auth route does not move. ## How it works The handshake has four steps. 1. Client connects via [WebSocket](/glossary/websocket). The service assigns a `socket_id`. 2. Client requests a subscription to `private-room-42`. The SDK POSTs `socket_id` and `channel_name` to the configured auth endpoint. 3. Application server runs its authorization logic. If allowed, it computes: ``` signature = HMAC-SHA256(app_secret, "{socket_id}:{channel_name}") auth = "{app_key}:{signature}" ``` For presence channels it also includes `channel_data` (a JSON object containing `user_id` and optional `user_info`) and signs `"{socket_id}:{channel_name}:{channel_data}"` instead. The endpoint returns `{ "auth": "...", "channel_data": "..." }`. 4. The SDK forwards the token in the `pusher:subscribe` frame. The service recomputes the signature with its own copy of the secret and accepts the subscription if they match. A minimal Laravel auth route is just: ```php Broadcast::channel('room-{roomId}', function ($user, $roomId) { return $user->canJoin(Room::find($roomId)); }); ``` Laravel's `BroadcastServiceProvider` wires `/broadcasting/auth` to this routing layer automatically. ## Related terms - [Private channel](/glossary/private-channel) is the most common channel auth client. - [Presence channel](/glossary/presence-channel) extends channel auth with a `channel_data` payload. - [Pusher protocol](/glossary/pusher-protocol) defines the signature format channel auth uses. ## See also - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for the Laravel BroadcastServiceProvider conventions. - [/migrate/pusher-to-vask-laravel](/migrate/pusher-to-vask-laravel) for how the auth route stays unchanged across a host swap. --- title: Concurrent connections (definition). The gauge that sets real-time tier. type: glossary source: https://vask.dev/glossary/concurrent-connections --- # Concurrent connections Concurrent connections is the count of WebSockets currently open between clients and a real-time service. The metric is a gauge, not a counter: it rises when a client connects and falls when one disconnects, with no accumulation over time. ## Why it matters Concurrent connections is the primary pricing dimension for most real-time providers, including Vask. It maps cleanly to capacity (each connection consumes a socket, some memory, and some kernel state on the edge node) and it scales with the size of the live audience, not with the publish rate. That makes it a more stable, more predictable billing axis than per-message or per-delivery counts. It is also the dimension that determines a Vask tier. The published tiers (Free, Side, Indie, Business) are differentiated by their concurrent-connections cap, with broadcast and per-app counts as supporting limits. Sizing a tier comes down to predicting peak simultaneous users. The cap is a soft signal in practice. Crossing it briefly during a viral moment triggers a grace period, not an immediate cutoff. Caps exist to protect customers from surprise overage, not to fail in real time. ## How it works A connection appears on the gauge when a client completes the [WebSocket](/glossary/websocket) handshake and the service accepts it. It disappears when either side closes the socket. There is no in-between accounting; either the socket is open and counted, or it is not. Pricing models typically sample this gauge: - **Peak sampling.** The highest measured concurrent count during the billing window. Punishes viral spikes. - **95th percentile.** Drops the top 5% of samples. Forgiving of brief spikes; sensitive to sustained load. - **Average.** Total connection-seconds divided by billing seconds. Smooth, but slow to react to growth. Vask uses peak with a grace window for the headline tier limit. This rewards steady operations and gives a planning runway when a feature lands well. A few wire-level details. One client typically opens one connection, regardless of how many channels it subscribes to. Channel subscriptions are multiplexed inside the single WebSocket via the [Pusher protocol](/glossary/pusher-protocol). A user with 50 open channel subscriptions still counts as 1 concurrent connection. ## Related terms - [WebSocket](/glossary/websocket) is the connection that gets counted. - [Fan-out](/glossary/fan-out) and concurrent connections move together: each new connection adds one to the maximum possible fan-out factor on every channel it subscribes to. - [Broadcast](/glossary/broadcast) is the work unit that the connection count does NOT include. ## See also - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for tier-by-tier connection caps under both protocols. - [/migrate/pusher-to-vask-laravel](/migrate/pusher-to-vask-laravel) for sizing a Vask tier off existing Pusher usage data. --- title: Fan-out (definition). WebSocket broadcast delivery to N subscribers. type: glossary source: https://vask.dev/glossary/fan-out --- # Fan-out Fan-out is the WebSocket delivery pattern where one broadcast published to a channel is sent to every subscriber of that channel. One publish in, N deliveries out. The N is the fan-out factor for that broadcast. ## Why it matters Fan-out is the work pattern that defines modern real-time apps. Notifications, presence indicators, typing dots, live cursors, leaderboards, collaborative editing: all of them are broadcast-once, deliver-to-many. As subscriber counts grow, the fan-out factor grows linearly. A channel with ten subscribers fans out to ten clients per broadcast. A channel with ten thousand subscribers fans out to ten thousand. The fan-out factor is not under the publisher's control. The publisher emits one broadcast. The service decides how many subscribers exist and delivers accordingly. This matters because pricing models that bill per delivered copy attach the cost of growth to a dimension the engineer cannot directly cap. Pricing models that bill per broadcast attach cost to the dimension the engineer can control (publish rate). ## How it works A subscriber opens a [WebSocket](/glossary/websocket) connection to the real-time service and sends a `pusher:subscribe` frame naming a channel. The service registers the connection against that channel's subscriber set. When a publisher sends a [broadcast](/glossary/broadcast) to the channel, the service iterates the subscriber set and pushes a copy of the event frame to each one over its existing connection. The fan-out is server-side. The client never sees N. From the publisher's perspective the work is a single HTTP POST or single WebSocket frame. From the service's perspective it is N socket writes, where N is the live subscriber count at the instant of broadcast. Quick example. A typing-indicator broadcast on a channel with 200 subscribers produces: - 1 broadcast from the publisher - 200 fan-out deliveries from the service - 200 inbound frames at clients The same broadcast on a channel with 2 subscribers produces 2 deliveries. The publisher code is identical. Only the live subscriber count changed. ## Related terms - [Broadcast](/glossary/broadcast) is the unit the publisher hands off. Fan-out is what happens to it. - [Fan-out tax](/glossary/fan-out-tax) is the per-subscriber-multiplier billing model some services attach to fan-out. - [Concurrent connections](/glossary/concurrent-connections) is the gauge that determines the maximum possible fan-out factor. ## See also - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the math on what fan-out billing does to a real-world invoice. - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for how Laravel broadcasts compose with fan-out under the hood. --- title: Fan-out tax (definition). The per-subscriber multiplier on real-time bills. type: glossary source: https://vask.dev/glossary/fan-out-tax --- # Fan-out tax The fan-out tax is the per-delivered-copy surcharge some real-time services attach to broadcasts. One publish to a channel with N subscribers produces a bill of N plus 1 messages (one for the publish, one for each delivered copy), rather than 1 message. ## Why it matters The fan-out tax is the single largest line-item driver on broadcast-heavy real-time invoices, and it is the one engineers most often miss until they cross a usage threshold. Pricing pages list a per-message rate. The mechanism that determines how many messages a single broadcast counts as is buried in the limits documentation. The result is that two services with identical-looking per-message rates can produce bills that differ by an order of magnitude on the same workload. The math compounds in three predictable moments: 1. The viral moment. Subscribers grow 10x. Every existing broadcast now fans out across 10x the audience. The bill grows roughly 10x even if publish frequency stays flat. 2. The product change. Someone ships presence indicators, typing dots, or cursor positions. These emit broadcasts at human-keystroke frequency, fanned out across every viewer. 3. The audit. Finance asks why the line item doubled. The honest answer is that broadcasting to your own users is the priced unit, and the price scales with audience. ## How it works Pricing models that apply a fan-out tax count messages at the delivery layer, not the publish layer. The vendor's documentation typically reads: if one message is published to a channel and 50 clients are subscribed, the message count is 51. One for the publish, 50 for the deliveries. The same workload under a flat broadcast model counts as one broadcast, regardless of subscriber count. The mechanism is the difference between billing the publisher and billing the [fan-out](/glossary/fan-out) itself. A simple worked example. A typing indicator emits 5 broadcasts per second while a user types. The room has 500 subscribers. - Per-fan-out model: 5 broadcasts \* 501 = 2,505 billable messages per second per typing user. - Per-broadcast model: 5 billable broadcasts per second per typing user. The same code on the same protocol produces a 500x difference in billed volume. ## Related terms - [Fan-out](/glossary/fan-out) is the mechanism. The tax is the surcharge attached to it. - [Broadcast](/glossary/broadcast) is the unit a flat-billing service prices instead. - [Concurrent connections](/glossary/concurrent-connections) is the alternative pricing gauge: bill on the connection count, not the delivery count. ## See also - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for a worked-numbers comparison on the same protocol under both billing models. - [/migrate/pusher-to-vask-laravel](/migrate/pusher-to-vask-laravel) for the credentials-swap migration that removes the fan-out tax from a Laravel app. --- title: Glossary. Real-time WebSocket terms. type: glossary source: https://vask.dev/glossary/index --- # Glossary Short definitions for the terms that show up across the rest of the site. Each entry is one page. Each page links to the related entries and to the compare, migrate, or learn pages where the term shows up in context. ## Terms - [Broadcast](/glossary/broadcast) is the single message a publisher sends to a channel, distinct from the per-subscriber copies that get delivered. - [Channel auth](/glossary/channel-auth) is the signed handshake that authorizes a client to subscribe to a private or presence channel. - [Concurrent connections](/glossary/concurrent-connections) is the count of WebSockets currently open against a real-time service, and the gauge that sets pricing tier. - [Fan-out](/glossary/fan-out) is the mechanism by which one broadcast on a channel is delivered to every subscriber of that channel. - [Fan-out tax](/glossary/fan-out-tax) is the per-delivered-copy surcharge some real-time services attach to broadcasts. - [Presence channel](/glossary/presence-channel) is a channel type that maintains a server-side member roster and emits join and leave events. - [Private channel](/glossary/private-channel) is a channel type that requires a server-side auth callback before a client may subscribe. - [Public channel](/glossary/public-channel) is a channel type that any connected client can subscribe to without auth. - [Pusher protocol](/glossary/pusher-protocol) is the open WebSocket protocol for channel subscriptions, named events, and auth. - [WebSocket](/glossary/websocket) is the persistent, bidirectional connection between a client and a server that real-time protocols run on top of. --- title: Presence channel (definition). Channels with member state and join/leave events. type: glossary source: https://vask.dev/glossary/presence-channel --- # Presence channel A presence channel is a [Pusher-protocol](/glossary/pusher-protocol) channel type that maintains a server-side roster of subscribed members and emits join and leave events as that roster changes. Channel names use the `presence-` prefix. ## Why it matters Presence channels are the right primitive for any feature that answers "who else is here." Document collaboration uses them for the avatars in the header. Live chat uses them for the online list. Multiplayer experiences use them for the lobby roster. The service holds the canonical roster, so clients do not have to reconcile state from a thin event stream. Presence is also one of the most fan-out-heavy channel types in practice. Every member.added and member.removed event broadcasts to every other subscriber. A channel with 200 members produces 200 deliveries per join and 200 per leave. This makes the per-broadcast versus per-delivered-copy billing distinction (see [fan-out tax](/glossary/fan-out-tax)) particularly load-bearing on presence-heavy workloads. ## How it works A client subscribes to a presence channel the same way it subscribes to a [private channel](/glossary/private-channel), with one addition: the [channel auth](/glossary/channel-auth) callback returns `channel_data` (a JSON object containing `user_id` and optional `user_info`) alongside the auth signature. The service uses `user_id` as the canonical identity for the member in the roster. After a successful subscribe, the client receives three internal events in order: - `pusher_internal:subscription_succeeded` with the full current member list. - `pusher_internal:member_added` for every subsequent join, fanned out to all members. - `pusher_internal:member_removed` for every leave. A typical Laravel server-side auth response looks like: ```json { "auth": "app-key:hmac-signature", "channel_data": "{\"user_id\":\"user-42\",\"user_info\":{\"name\":\"Ada\"}}" } ``` The `user_id` is the deduplication key. If the same user opens two tabs, the service emits member.added once for the first tab and ignores the duplicate from the second. member.removed fires when the last connection for that `user_id` drops. ## Related terms - [Private channel](/glossary/private-channel) is the auth-gated cousin without the roster. - [Channel auth](/glossary/channel-auth) is the handshake that authorizes presence subscriptions. - [Fan-out](/glossary/fan-out) is what every member.added and member.removed event triggers. ## See also - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for the Laravel broadcasting recipe that wires Presence routes and `channel_data`. - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the fan-out math on presence-heavy workloads. --- title: Private channel (definition). Auth-gated Pusher-protocol channels. type: glossary source: https://vask.dev/glossary/private-channel --- # Private channel A private channel is a [Pusher-protocol](/glossary/pusher-protocol) channel type that requires a signed auth token before a client can subscribe. Channel names use the `private-` prefix. The service refuses subscribe frames that lack a valid token signed with the app secret. ## Why it matters Private channels are the access-control surface of a real-time app. Without them, any client connected to the WebSocket service could subscribe to any named channel by guessing the name and receive every event broadcast on it. With them, the application server is the gatekeeper: it inspects the requesting session, decides whether the user is authorized to receive events on the channel, and signs a token only if the answer is yes. This pattern keeps real-time authorization aligned with the rest of the application. The auth callback runs inside the same HTTP request lifecycle as the rest of the app, so it has the user session, the database, the policy layer, and whatever else the application uses to make access decisions. ## How it works The handshake follows the [channel auth](/glossary/channel-auth) flow. 1. Client opens a [WebSocket](/glossary/websocket) connection. The service issues a `socket_id`. 2. Client calls `pusher.subscribe("private-user-42-notifications")`. The SDK POSTs to the configured auth endpoint with `socket_id` and `channel_name` in the body. 3. The application server inspects the session, checks authorization for that channel, and returns a JSON body containing an `auth` field of the form `:`. The signature is HMAC-SHA256 of `socket_id:channel_name` keyed with the app secret. 4. The client includes the signature in the `pusher:subscribe` frame. The service verifies the signature against the channel name and socket ID, and either accepts the subscription or returns `pusher:subscription_error`. A minimal Laravel auth route: ```php Broadcast::channel('user-{userId}-notifications', function ($user, $userId) { return (int) $user->id === (int) $userId; }); ``` The Laravel Broadcasting service maps the `private-user-42-notifications` channel to that closure, runs it, and either signs the token or returns 403. ## Related terms - [Presence channel](/glossary/presence-channel) is the same auth model plus a member roster. - [Public channel](/glossary/public-channel) is the no-auth counterpart. - [Channel auth](/glossary/channel-auth) is the signed handshake private channels rely on. ## See also - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for the Laravel BroadcastServiceProvider routes that gate private channels. - [/migrate/pusher-to-vask-laravel](/migrate/pusher-to-vask-laravel) for how the auth callback stays unchanged across a host swap. --- title: Public channel (definition). Open Pusher-protocol channels with no auth. type: glossary source: https://vask.dev/glossary/public-channel --- # Public channel A public channel is a [Pusher-protocol](/glossary/pusher-protocol) channel type that any client with a valid app key can subscribe to without an auth callback. Channel names carry no special prefix. Anything without `private-` or `presence-` at the start is public. ## Why it matters Public channels are the simplest real-time primitive. No auth route. No signed token. Open a [WebSocket](/glossary/websocket), send a `pusher:subscribe` frame, and the service starts delivering events. This makes them well-suited for genuinely public real-time surfaces: a public live ticker, a build-status feed, a leaderboard visible to anonymous visitors, an unauthenticated demo of a real-time feature. The flip side: there is no access control at the protocol layer. Anyone holding the app key can subscribe to any channel name. If you need access control, switch to a [private channel](/glossary/private-channel) or a [presence channel](/glossary/presence-channel). Do not rely on obscure channel names as a substitute for auth. ## How it works A subscribe frame for a public channel carries only the channel name. The service confirms the subscription with `pusher_internal:subscription_succeeded` and starts forwarding any matching [broadcast](/glossary/broadcast) events. ```json { "event": "pusher:subscribe", "data": { "channel": "live-ticker-eurusd" } } ``` The service does not call out to the application server during the handshake. The publisher remains authenticated against the service (HTTP requests are signed with the app secret), so the only thing that is public is the read side of the channel. A common pattern is to mix public and private channels in the same app. Marketing pages subscribe to public channels for non-sensitive live data. Authenticated routes subscribe to private channels for per-user data. The same client connection carries both. ## Related terms - [Private channel](/glossary/private-channel) is the auth-gated counterpart. - [Presence channel](/glossary/presence-channel) is auth plus a member roster. - [Channel auth](/glossary/channel-auth) is the handshake public channels skip. ## See also - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for routing public versus private channels in a Laravel app. - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the protocol-compatibility story across channel types. --- title: Pusher protocol (definition). The open WebSocket channel protocol. type: glossary source: https://vask.dev/glossary/pusher-protocol --- # Pusher protocol The Pusher protocol is an open application-layer protocol that runs over a [WebSocket](/glossary/websocket) connection. It defines channel subscription frames, named events, member presence on shared channels, and a signed auth handshake for restricted channels. The protocol is implemented by multiple independent servers and a broad ecosystem of client SDKs. ## Why it matters The Pusher protocol is one of the cleanest channel-based real-time protocols in production use. It has well-defined semantics for [public channels](/glossary/public-channel), [private channels](/glossary/private-channel), and [presence channels](/glossary/presence-channel), with SDKs in every language a working developer is likely to ship in. Because the protocol is the contract (not the host, not the vendor), applications written against it are portable across any server that implements it. This portability is the wedge for migrations. A Laravel app using `laravel-echo` and `pusher-js`, or a Rails app using `pusher-http-ruby`, or a Python app using `pusher-http-python`, all connect to any Pusher-protocol-compatible server by changing host and credentials. The application code that calls `channel.bind(event, handler)` does not change. ## How it works The protocol layers over a standard `wss://` connection. After the WebSocket handshake completes, a few frame shapes drive the entire flow. - `pusher:subscribe` to join a channel. Public channels accept the frame directly. Private and presence channels require an `auth` token returned by the application's [channel auth](/glossary/channel-auth) callback. - `pusher:unsubscribe` to leave. - Named application events on subscribed channels, delivered as JSON frames with `event`, `channel`, and `data` fields. - `pusher:ping` and `pusher:pong` for liveness. A minimal subscribe frame looks like: ```json { "event": "pusher:subscribe", "data": { "channel": "live-cursors-doc-42" } } ``` A delivered event frame looks like: ```json { "event": "cursor.moved", "channel": "live-cursors-doc-42", "data": "{\"x\":120,\"y\":340}" } ``` The protocol also defines `pusher_internal:*` events for system signals (member join, member leave, subscription confirmed) which the SDKs surface as ergonomic callbacks. ## Related terms - [WebSocket](/glossary/websocket) is the transport the protocol runs on. - [Channel auth](/glossary/channel-auth) is the handshake for joining restricted channels. - [Broadcast](/glossary/broadcast) is the unit of work the protocol delivers as an event frame. ## See also - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the protocol-compatibility wedge in practice. - [/migrate/pusher-to-vask-laravel](/migrate/pusher-to-vask-laravel) for a credentials-swap migration on the same wire protocol. - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for how Laravel's broadcasting stack composes with this protocol end to end. --- title: WebSocket (definition). The bidirectional transport behind real-time apps. type: glossary source: https://vask.dev/glossary/websocket --- # WebSocket A WebSocket is a persistent, bidirectional connection between a client and a server, defined by RFC 6455. The connection starts as an HTTP request, upgrades to the WebSocket protocol via a `Connection: Upgrade` handshake, and then carries framed binary or text messages in either direction until either side closes it. ## Why it matters WebSockets are the transport layer for nearly every modern real-time feature: chat, presence, live cursors, notifications, collaborative editing, game state, live dashboards. The defining property is that the server can push to the client without the client polling. Latency drops from polling-interval-bound (seconds) to network-RTT-bound (tens of milliseconds). For a real-time service, the WebSocket is the substrate. Everything else (the [Pusher protocol](/glossary/pusher-protocol), [channel auth](/glossary/channel-auth), [presence channels](/glossary/presence-channel), [fan-out](/glossary/fan-out)) sits on top of the WebSocket as application-layer semantics. Choosing where to terminate the WebSocket (regional, multi-region, edge) is the single biggest latency decision in a real-time architecture. ## How it works A WebSocket connection has three phases. **Handshake.** The client sends an HTTP/1.1 GET with `Upgrade: websocket`, `Connection: Upgrade`, and a `Sec-WebSocket-Key` header. The server responds with `101 Switching Protocols` and a derived `Sec-WebSocket-Accept` header. After that response, the connection is no longer an HTTP exchange; it is a framed WebSocket. **Data frames.** Either side can send a frame at any time. Frames have an opcode (text, binary, close, ping, pong) and a payload. Text frames carry UTF-8 strings, typically JSON in real-time protocols. Binary frames carry arbitrary bytes. **Close.** Either side sends a close frame. The other replies. The TCP connection terminates. A minimal client-side connection in browser JS: ```js const ws = new WebSocket('wss://ws.example.com/app/your-app-key'); ws.onopen = () => ws.send( JSON.stringify({ event: 'pusher:subscribe', data: { channel: 'live' }, }), ); ws.onmessage = (e) => console.log('frame', e.data); ws.onclose = () => console.log('disconnected'); ``` Higher-level SDKs (pusher-js, laravel-echo) wrap this with auto-reconnect, channel multiplexing, [private channel](/glossary/private-channel) auth, and presence state, so applications rarely interact with the raw `WebSocket` object directly. Each open WebSocket counts as one entry on the [concurrent connections](/glossary/concurrent-connections) gauge for the real-time service, regardless of how many channels it subscribes to. ## Related terms - [Pusher protocol](/glossary/pusher-protocol) is the application layer that runs over a WebSocket. - [Channel auth](/glossary/channel-auth) is the per-subscription handshake on top of the WebSocket connection. - [Concurrent connections](/glossary/concurrent-connections) is the count of open WebSockets at a moment in time. ## See also - [/learn/websockets-in-laravel](/learn/websockets-in-laravel) for the WebSocket story end to end in a Laravel app. - [/compare/pusher-vs-vask](/compare/pusher-vs-vask) for the substrate question (centralized region versus edge termination).