How Kaneo Uses WebSockets for Real-Time Task Updates and Notifications

Kaneo delivers real-time updates through a lightweight WebSocket layer that tracks project-scoped and user-scoped connections, using a pluggable BroadcastAdapter to support both single-instance deployments and Redis-backed horizontal scaling.

Kaneo is an open-source project management platform that synchronizes team activity through instant UI updates. The application implements a custom WebSocket architecture within its API service to push live task changes, status updates, and notifications without requiring page refreshes. This system is designed to be transport-agnostic, allowing it to scale from single-instance deployments to multi-node clusters using Redis pub/sub.

Connection Management and Scoping

The WebSocket implementation maintains two distinct connection registries to separate project-wide broadcasts from private user notifications. This scoping happens in apps/api/src/ws/index.ts, where the system initializes Maps to track active sockets.

Tracking Project and User Connections

When a client connects, the API registers the socket in either projectConnections or userConnections depending on the subscription type. The addConnection() function in apps/api/src/ws/index.ts handles this registration, associating each WebSocket with a specific project ID, user ID, and a unique initiator ID for exclusion logic【^1†L17-L34】.

  • Project-scoped connections live in the projectConnections Map and receive task updates, label changes, and board modifications.
  • User-scoped connections live in userConnections and receive private notifications such as NOTIFICATION_CREATED events.

When a client disconnects, removeConnection() cleans up the registry to prevent memory leaks【^1†L39-L50】.

// Register a connection when a client subscribes to a project
import { addConnection } from "@kaneo/api/ws";
const conn = addConnection(projectId, wsContext, userId, initiatorId);

// Clean up on disconnect
import { removeConnection } from "@kaneo/api/ws";
removeConnection(projectId, conn);

The Broadcast Abstraction Layer

Kaneo abstracts message delivery behind a BroadcastAdapter interface, allowing the same business logic to operate in development (single instance) and production (multi-instance) environments without code changes.

The BroadcastAdapter Interface

The interface defined in apps/api/src/ws/broadcast-adapter.ts specifies two channels: PROJECT_BROADCAST and USER_BROADCAST. Any adapter must implement the publish() method to distribute messages across these channels【^2†L26-L39】.

Adapter Implementations

The API ships with two concrete implementations:

On startup, initializeWebSocketAdapter() in apps/api/src/ws/index.ts selects the appropriate adapter based on whether Redis is configured (isRedisConfigured()), then subscribes to both broadcast streams【^1†L6-L14】【^1†L15-L33】.

Broadcasting Strategies and Message Delivery

The WebSocket layer optimizes message delivery through intelligent batching for project updates while maintaining immediacy for user-specific alerts.

Batched Project Broadcasts

The broadcastToProject() function manages a per-project message queue. When multiple updates occur rapidly—such as during bulk task operations—the system aggregates these into a single batch. A 100ms timeout ensures quick delivery while reducing network overhead【^1†L52-L64】.

Messages include an optional excludeInitiatorId parameter to prevent echoing updates back to the client that triggered the change:

import { broadcastToProject } from "@kaneo/api/ws";

// Broadcast task update to all project members except the editor
broadcastToProject(projectId, {
  type: "TASK_UPDATED",
  projectId,
  taskId,
  changes: { status: "completed" }
}, initiatorId);

Direct User Notifications

For time-sensitive alerts, broadcastToUser() bypasses batching and delivers immediately. The function first attempts local delivery via deliverToLocalUserConnections(), then forwards to the adapter for remote instances. Remote instances ignore messages tagged with their own instance UUID to prevent duplicate delivery【^1†L66-L84】.

import { broadcastToUser } from "@kaneo/api/ws";

// Push a private notification to a specific user
broadcastToUser(userId, { 
  type: "NOTIFICATION_CREATED",
  notificationId: "123e4567-e89b-12d3-a456-426614174000"
});

Event-Driven Real-Time Updates

Kaneo decouples business logic from transport details through an internal event system. The WebSocket layer subscribes to domain events and translates them into broadcast calls.

When the notification.created event fires, the handler invokes broadcastToUser() to push the alert to the recipient's private connection【^1†L37-L44】. Similarly, project-wide events like task.created, task.moved, or label.updated trigger broadcastToProject(), ensuring all active project participants see changes instantaneously without polling.

Scaling with Redis Pub/Sub

For horizontally scaled deployments, the RedisBroadcastAdapter handles cross-instance communication. Each API instance maintains its local connection maps while listening to the Redis channel for messages originating from other nodes. When a message arrives from Redis, the adapter routes it to the appropriate local connections based on the message type (project or user scope).

This design allows Kubernetes-deployed Kaneo instances to maintain stateful WebSocket connections while sharing broadcast state through Redis, eliminating the need for sticky sessions.

Summary

  • Kaneo tracks WebSocket connections in two Maps: projectConnections for project-wide updates and userConnections for personal notifications.
  • The BroadcastAdapter interface abstracts delivery, allowing the same code to run in single-instance (in-memory) or multi-instance (Redis) deployments.
  • Project updates are batched with a 100ms delay to reduce network overhead while preserving event ordering.
  • Domain events like task.created and notification.created automatically trigger WebSocket broadcasts through broadcastToProject and broadcastToUser.

Frequently Asked Questions

How does Kaneo handle WebSocket connections when scaling to multiple API instances?

When Redis is configured, Kaneo initializes the RedisBroadcastAdapter in apps/api/src/ws/index.ts. This adapter publishes messages to a dedicated Redis pub/sub channel, allowing stateless API instances to receive broadcasts from other nodes and deliver them to locally connected clients. Each instance ignores messages tagged with its own unique UUID to prevent echo loops.

What is the difference between project-scoped and user-scoped WebSocket connections?

Project-scoped connections stored in projectConnections receive collaborative updates such as task modifications, status changes, and label assignments. User-scoped connections in userConnections carry private, non-collaborative data like system notifications and direct mentions. This separation ensures users only receive relevant data, reducing client-side processing and bandwidth consumption.

How does Kaneo prevent broadcast storms during rapid task updates?

The broadcastToProject() function implements a per-project message queue with a 100ms aggregation window. Rapid successive updates to the same project—such as bulk status changes—are collected into a single batch before transmission. This throttling mechanism reduces duplicate traffic while maintaining the chronological order of events through FIFO queue processing.

Where does the WebSocket adapter initialization occur in the codebase?

The initializeWebSocketAdapter() function resides in apps/api/src/ws/index.ts and executes during API startup. It evaluates the Redis configuration status via isRedisConfigured(), instantiates the appropriate adapter (in-memory or Redis), and establishes subscriptions to both project-wide and user-wide broadcast channels【^1†L6-L14】.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →