Skip to content

Rate Limiting

This guide demonstrates rate limiting client messages to prevent abuse. The server can track rate limits per user, per document, or per user-document pair.

  • Configuring rate limiting on the server
  • Tracking rate limits by user, document, or user-document pair
  • Using persistent rate limit storage for multi-node deployments
  • Handling rate limit exceeded events
import { Server } from "teleportal/server";
import { createStorage } from "unstorage";
import { UnstorageDocumentStorage, UnstorageRateLimitStorage } from "teleportal/storage";
const storage = createStorage();
const server = new Server({
storage: new UnstorageDocumentStorage(storage),
rateLimitConfig: {
// Multiple rate limit rules
rules: [
// Track by user (across all documents)
{
id: "per-user",
maxMessages: 100, // 100 messages per window
windowMs: 1000, // 1 second window
trackBy: "user",
},
// Track by document (across all users)
{
id: "per-document",
maxMessages: 500, // 500 messages per window per document
windowMs: 10000, // 10 second window
trackBy: "document",
},
// Track by user-document pair
{
id: "user-document",
maxMessages: 100,
windowMs: 1000,
trackBy: "user-document",
},
],
// Use persistent storage for multi-node deployments
rateLimitStorage: new UnstorageRateLimitStorage(storage),
// Callback when rate limit is exceeded
onRateLimitExceeded: (details) => {
console.warn("Rate limit exceeded", details);
},
// Maximum message size
maxMessageSize: 10 * 1024 * 1024, // 10MB
// Callback when message size is exceeded
onMessageSizeExceeded: (details) => {
console.warn("Message size exceeded", details);
},
},
});
  • "user": Track rate limits per user ID. All connections from the same user share the same limit.
  • "document": Track rate limits per document ID. All users editing the same document share the same limit.
  • "user-document": Track rate limits per user-document pair. Each user has separate limits for each document.
  • "transport": Track rate limits per transport instance (in-memory only, not shared).

Rate limiting uses a token bucket algorithm and is inbound-only: only messages arriving from clients are limited. Server-originated broadcasts are passed through untouched (silently dropping a doc update would permanently diverge a client until a full resync; egress is already bounded because every broadcast originates from a rate-limited ingress message).

Each tracking key (user, document, or user-document pair) gets a bucket that holds up to maxMessages tokens. Every incoming message consumes one token. Tokens refill at a steady rate of maxMessages per windowMs, so short bursts (up to maxMessages at once) are allowed while an average rate is enforced over time.

When a message arrives and the bucket is empty, it is held (delayed), not immediately dropped (default behavior). The message waits until its bucket refills, up to maxDelayMs (default 1000ms). Because each connection’s inbound stream is consumed sequentially, holding naturally throttles the sender losslessly. Only a wait that would exceed the budget causes the message to be dropped – and a dropped message fires onRateLimitExceeded and is nacked (an ACK carrying retryAfter) so the client can retransmit, never thrown. Set maxDelayMs: 0 for legacy drop-only behavior.

When multiple rules apply, all rules must pass, and a message rejected by a later rule refunds the tokens it already consumed from earlier rules, so a client’s retransmit isn’t double-charged.

ACK messages are automatically excluded from rate limiting since they are protocol-level acknowledgments, not user-initiated traffic. Rate limiting is also applied before permission checking – messages that exceed the limit are held or nacked without incurring the cost of a permission check.

Both maxMessages and windowMs on each rule accept either a static number or a function that receives the current message and returns a number. This lets you vary limits based on user role, document type, or any other context.

import { Server } from "teleportal/server";
const server = new Server({
// ...
rateLimitConfig: {
rules: [
{
id: "per-user",
maxMessages: (msg) => {
if (msg.context.role === "premium") return 500;
return 100;
},
windowMs: (msg) => {
// Tighter window for public documents
if (msg.document?.startsWith("public/")) return 500;
return 1000;
},
trackBy: "user",
},
],
},
});

Use shouldSkipRateLimit to bypass rate limiting entirely for certain messages. This is useful for admin users, internal service accounts, or specific message types that should never be throttled.

import { Server } from "teleportal/server";
const server = new Server({
// ...
rateLimitConfig: {
rules: [
{
id: "per-user",
maxMessages: 100,
windowMs: 1000,
trackBy: "user",
},
],
shouldSkipRateLimit: async (msg) => {
return msg.context.role === "admin";
},
},
});

The onRateLimitExceeded callback fires every time a message is dropped due to rate limiting (after exhausting its allowed hold time). It receives structured details about the event, making it straightforward to integrate with logging and alerting systems. Note that resetAt is the timestamp when the next token refills – not the end of a full window – so a nacked sender only waits the fractional remainder of a single token before retrying.

import { Server } from "teleportal/server";
const server = new Server({
// ...
rateLimitConfig: {
rules: [
{
id: "per-user",
maxMessages: 100,
windowMs: 1000,
trackBy: "user",
},
],
onRateLimitExceeded: (details) => {
logger.warn("Rate limit exceeded", {
ruleId: details.ruleId,
userId: details.userId,
documentId: details.documentId,
currentCount: details.currentCount,
maxMessages: details.maxMessages,
resetAt: new Date(details.resetAt).toISOString(),
});
// Send to your alerting service
alertService.notify("rate-limit-exceeded", details);
},
onMessageSizeExceeded: (details) => {
logger.warn("Message size exceeded", {
size: details.size,
maxSize: details.maxSize,
});
},
},
});

For multi-node deployments, use persistent rate limit storage so that limits are shared across all server instances:

import { RedisRateLimitStorage } from "teleportal/transports/redis";
const rateLimitStorage = new RedisRateLimitStorage(redisClient);
const server = new Server({
// ... other options
rateLimitConfig: {
rateLimitStorage, // Shared across server instances
// ... other options
},
});

Without persistent storage, each server instance tracks limits independently (in-memory per transport), so a user could exceed the intended limit by connecting to multiple nodes.

  • Server - Learn more about server configuration
  • Scaling - Multi-node deployment strategies