When Kismaa — a real-time psychic experience platform — needed to match users with available practitioners in under 3 seconds, we knew a simple database polling approach wouldn't work. With hundreds of concurrent users and practitioners online at any moment, the matching engine needed to handle O(n×m) comparisons efficiently while maintaining WebSocket connections for real-time communication. Here's the queue-based architecture that solved it.
The Matching Problem
The matching engine needed to consider multiple factors simultaneously: practitioner availability (online status, current load), user preferences (language, specialty, rating range), queue position (fairness), and session type (chat, video, phone). Each factor carried a weight, and the final score determined match priority. The challenge: recalculate matches every time a practitioner comes online, goes offline, or a new user enters the queue.
BullMQ Job Queues
We used BullMQ (Redis-backed) to decouple match calculation from WebSocket connection management. When a user enters the queue, a 'match-request' job is added. When a practitioner comes online, a 'match-recalculate' job is triggered. The queue processor runs match scoring in batches, comparing pending requests against available practitioners. This decoupling meant WebSocket connections stayed lightweight — they only needed to handle real-time messages, not complex scoring logic.
Note
BullMQ's job scheduling features (delayed jobs, repeatable jobs, rate limiting) were essential for handling retry logic and preventing match calculation storms.
Redis as the Real-Time State Store
We stored active practitioner state in Redis hashes: online status, current session count, specialty scores, and real-time load metrics. WebSocket connections published presence events to Redis Pub/Sub, and the matching engine subscribed to these events for instant state updates. Redis's sub-millisecond latency meant match calculations always operated on fresh data — critical for avoiding double-bookings.
Graceful Degradation Under Load
During peak hours (evenings and weekends), match requests exceeded our target processing time. We implemented a tiered degradation: (1) Under 100 concurrent users: full scoring algorithm. (2) 100-300: simplified scoring (availability + specialty only). (3) 300+: queue position priority with best-available matching. This ensured sub-3-second matching even under extreme load, with the understanding that match quality slightly decreased as load increased.
Conclusion
Real-time matching is a streaming problem, not a batch problem. By decoupling WebSocket connections from matching logic via BullMQ, we built a system that scaled horizontally (add more queue workers) without touching the connection layer. Redis provided the shared state store that made this possible. The key lesson: don't match in real-time — match in near-real-time and communicate results in real-time.
Key Takeaways
- BullMQ decouples match calculation from WebSocket connections for independent scaling
- Redis hashes store practitioner state with sub-millisecond latency for fresh match data
- Tiered degradation ensures sub-3-second matching even under extreme load
- Don't match in real-time — match in near-real-time and communicate results in real-time