The reactions system provides a dedicated, burst-tolerant real-time channel for viewer reactions (hearts, laughs, wow, fire, clap emojis) on streams. Unlike chat, reactions are:
- Ephemeral: Never persisted to database
- Aggregated: Individual taps are batched into counts per reaction type
- Burst-tolerant: Rate limiter allows rapid tapping without flooding
- Lightweight: No animation overhead from high-volume events
- useReactions hook - Manages UI state and sends reactions
- POST /api/streams/reactions - Receives reaction, applies rate limit, publishes to realtime
- Rate Limiter - Token bucket (20 token burst, 1/sec refill)
- Realtime Pub/Sub - Publishes raw reaction event to
stream:${streamId}:reactions
- POST /api/realtime/reactions/broadcast - Aggregates recent raw reactions into counts
- Realtime Pub/Sub - Broadcasts aggregated counts to
stream:${streamId}:reactions:aggregated - useReactions hook - Receives aggregated counts, updates UI
- ReactionBurst component - Renders animations based on density (light/medium/heavy)
import { useReactions, useReactionKeyboard } from "@/hooks/useReactions";
import { ReactionBar, ReactionBurst } from "@/components/reactions";
export function StreamViewer({ playbackId, viewerId }) {
const reactions = useReactions({
playbackId,
viewerId,
enabled: true,
});
useReactionKeyboard(true, reactions.sendReaction);
return (
<>
{/* Reaction animations */}
{Object.entries(reactions.aggregatedReactions).map(
([type, count]) =>
count > 0 && (
<ReactionBurst
key={type}
reactionType={type as ReactionType}
density={reactions.getReactionDensity(type as ReactionType)}
count={count}
/>
)
)}
{/* Reaction buttons */}
<ReactionBar
reactions={reactions.aggregatedReactions}
onReactionClick={reactions.sendReaction}
/>
</>
);
}The server needs to periodically call POST /api/realtime/reactions/broadcast for each active stream.
Option A: Qstash Cron (serverless, recommended)
curl -X POST https://api.qstash.io/v2/schedules \
-H "Authorization: Bearer $QSTASH_TOKEN" \
-d '{
"destination": "https://your-domain.com/api/realtime/reactions/broadcast",
"method": "POST",
"body": "{\"streamId\": \"stream-123\"}",
"cron": "*/5 * * * * *",
"headers": {
"Authorization": "Bearer $INTERNAL_SECRET"
}
}'Option B: Traditional Cron Job
Run every 200-500ms for active streams:
* * * * * for stream_id in $(curl -s http://localhost:3000/api/streams/active | jq -r '.[]'); do \
curl -X POST http://localhost:3000/api/realtime/reactions/broadcast \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SECRET" \
-d "{\"streamId\": \"$stream_id\"}" \
doneThe token endpoint already includes reactions channels in the whitelist:
stream:${streamId}:reactions(raw event input)stream:${streamId}:reactions:aggregated(aggregated output for viewers)
No additional auth setup needed.
- Initial burst: 20 rapid taps allowed instantly
- Sustained rate: 1 tap per second refills
- Per-user, per-stream, per-reaction-type
- Prevents abuse while allowing legitimate rapid-tap sequences
- Flush interval: 200-500ms configurable
- Input traffic: High-frequency raw reactions →
stream:${streamId}:reactions - Output traffic: Single aggregated batch per flush →
stream:${streamId}:reactions:aggregated - Broadcast reduction: 1000+ raw events → 1 broadcast with counts
Example: 10,000 viewers each tapping a reaction 5 times:
- Without aggregation: 50,000 broadcasts to all viewers (catastrophic)
- With aggregation: ~1 broadcast per 200ms with reaction counts (efficient)
Reactions are recorded to Redis with daily counters (non-real-time, batch-inserted):
- Key:
reactions:${streamId}:daily:YYYY-MM-DD - Field:
${reactionType}:count - TTL: 30 days
Query example:
const dailyCount = await redis.hgetall(
`reactions:stream-123:daily:2024-01-15`
);
// { "heart:count": "4523", "laugh:count": "203", ... }User rapidly taps heart button:
t=0ms: tryConsume → ✓ (tokens: 19)
t=10ms: tryConsume → ✓ (tokens: 18)
t=20ms: tryConsume → ✓ (tokens: 17)
...
t=190ms: tryConsume → ✓ (tokens: 0)
t=200ms: tryConsume → ✗ (rate limited)
t=1200ms: tryConsume → ✓ (refilled 1, tokens: 0)
Automated spam every 10ms:
t=0ms: tryConsume → ✓ (tokens: 19)
...
t=190ms: tryConsume → ✓ (tokens: 0)
t=200ms: tryConsume → ✗ (rate limited)
t=210ms: tryConsume → ✗ (rate limited)
...
t=1200ms: tryConsume → ✓ (refilled 1, tokens: 0)
t=1210ms: tryConsume → ✗ (rate limited)
Result: Attacker floods at 100 events/second but only ~5 are allowed per second.
Adjust in lib/reactions/rate-limiter.ts:
export const REACTION_LIMITER_CONFIG: RateLimiterConfig = {
capacity: 20, // Initial tokens (burst size)
refillRate: 1, // Tokens added per second
maxBurst: 20, // Maximum tokens at once
};Examples:
- Generous:
capacity: 50, refillRate: 2, maxBurst: 50(less aggressive rate limiting) - Strict:
capacity: 10, refillRate: 0.5, maxBurst: 10(more restrictive) - Per-minute limit:
capacity: 60, refillRate: 1, maxBurst: 60(1 per second sustained)
Run reaction tests:
npm test -- __tests__/lib/reactionsTests cover:
- ✓ Burst allowance (20 rapid taps)
- ✓ Token refill over time
- ✓ Max burst ceiling
- ✓ Per-user and per-reaction separation
- ✓ Aggregation at high volume (1000+ events)
- ✓ Rate limit evasion prevention
- Reactions per minute by stream/reaction type
- Rate limit rejection rate (should be <1% for legitimate users)
- Aggregation flush latency (should be <200ms)
- Realtime channel subscriber count on reactions channels
[reactions] Failed to send reaction→ Client-side errors[reactions-broadcast] Batch published→ Server aggregation working[reactions] Rate limited→ Heavy activity or abuse[reactions-aggregator] Flush callback failed→ Broadcast issues
- Custom emoji reactions (per-stream configurable set)
- Reaction pinning (highlight trending reactions)
- Creator earnings (tips triggered by reactions?)
- Moderation (block certain emojis per stream)
- Analytics dashboard (reaction heatmaps over time)