# BullMQ Implementation Guide: Modern Job Queue Architecture
Issue #104 | State: OPEN | Created: 2026-02-02T07:41:07Z
Labels: documentation, enhancement
Assignees: linked0
Updated: 2026-02-02T11:25:52Z | Closed: N/A
π Educational Overview
This document explains BullMQ, a Redis-based job queue system, and how it will transform our background job processing from database polling to event-driven architecture.
π― What is BullMQ?
BullMQ is the industry-standard job queue library for Node.js applications. It uses Redis as a message broker to handle background jobs efficiently.
Core Concept
Instead of constantly checking a database for pending jobs (polling), BullMQ uses Redis Pub/Sub to instantly notify workers when new jobs arrive (event-driven).
Real User Scenario π€
Meet Sarah: A user on Nostra who wants to trade on the market "Will Bitcoin reach $100k by end of 2026?"
Current System (Database Polling):
Sarah: [Opens market] "I think YES! Let me buy 100 shares at $0.65"
Sarah: [Clicks "Buy"] π±οΈ
Browser: "Trade pending... β³" [Shows spinner]
[Meanwhile, in the backend...]
Transaction Processor: [Polling database every 3 seconds]
"Any trades? No... Any trades? No... Any trades? YES!"
[Finally processes after 0-3 second delay]
[Executes on blockchain - 2 seconds]
Sarah: [Still sees spinner for 2-5 seconds total]
Sarah: "Is this working? Why is it so slow?" π
[Finally...]
Browser: "Trade executed!" β
Sarah: [Refreshes page to see updated balance]
With BullMQ (Event-Driven):
Sarah: [Opens market] "I think YES! Let me buy 100 shares at $0.65"
Sarah: [Clicks "Buy"] π±οΈ
Browser: "Executing trade..." β³
[Meanwhile, in the backend...]
API Server: [Adds job to Redis queue - <1ms]
Worker Service: [Instantly notified via Redis Pub/Sub] π
[Immediately starts executing on blockchain]
Browser: [WebSocket updates in real-time]
"Preparing transaction... 20% ββββββββββ"
"Broadcasting to blockchain... 50% ββββββββββ"
"Waiting for confirmation... 75% ββββββββββ"
"Updating balances... 90% ββββββββββ"
[2 seconds later...]
Browser: "Trade executed! β
TX: 0xabc123..."
[Balance updates automatically, no refresh needed]
[Shows transaction link to BSC Testnet explorer]
Sarah: "Wow, that was fast! And I could see what was happening!" π
The Difference:
- Current: 2-5 seconds with no feedback = "Is it broken?" π
- BullMQ: ~2 seconds with real-time updates = "This is smooth!" π
Real-World Analogy: Nostra Prediction Market π‘
Current System (Database Polling) = Transaction Processor Checking Database Every 3 Seconds
Transaction Processor Worker: "Are there pending trades?" [queries database]
Database: "No pending trades"
[3 seconds pass...]
Transaction Processor Worker: "Are there pending trades?" [queries database]
Database: "No pending trades"
[3 seconds pass...]
Transaction Processor Worker: "Are there pending trades?" [queries database]
Database: "Yes! User wants to buy 100 YES shares on 'Bitcoin $100k' market"
Transaction Processor Worker: [Finally starts executing trade on blockchain]
MEANWHILE:
User: [Placed trade 2.5 seconds ago, still waiting...] π
User's browser: "Trade pending... β³"
Result:
- Wasted database queries every 3 seconds (even when no trades)
- Delay: User's trade waits up to 3 seconds before blockchain execution starts
- If 10 API servers running: 10 workers all querying database simultaneously
- Database overload during high trading volume
BullMQ (Event-Driven) = Instant Trade Notification System
User: "Buy 100 YES shares on 'Bitcoin $100k'" [clicks button]
API Server: [Adds trade to Redis queue] π "NEW TRADE!"
Transaction Processor Worker: [Instantly notified] "Got it! Executing on blockchain..."
[Signs transaction, broadcasts to BSC Testnet]
[Transaction confirmed β
]
API Server: [Receives completion event] β WebSocket β User
User's browser: "Trade executed! TX: 0xabc123..." β
MEANWHILE (if 10 API servers running):
- Only 1 worker picks up the trade (no duplicates)
- Other 9 workers remain idle (no wasted queries)
- Redis coordinates everything automatically
Result:
- Zero database polling (instant notification via Redis Pub/Sub)
- Instant processing (0ms delay)
- User sees "Trade executed!" within ~3 seconds (blockchain time, not queue time)
- 10 API servers = same performance as 1 server (Redis handles coordination)
Another Example: Price Snapshots πΈ
Current System (node-cron polling):
Every 30 seconds, EACH API server runs:
API Server 1: "Time to capture price snapshots!" [queries all 50 active markets]
[Inserts 100 rows to price_history table (YES + NO for each market)]
API Server 2: "Time to capture price snapshots!" [queries same 50 markets]
[Inserts 100 rows to price_history table] β DUPLICATE!
API Server 3-10: [All doing the same thing...] β 10x DATABASE WRITES!
Database: [Overloaded with 1,000 duplicate price snapshots every 30 seconds] π₯
Result:
- 10x more database writes than needed
- Possible race conditions (duplicate data)
- Database performance degrades
BullMQ (scheduled jobs):
Redis Scheduler: "Time for price snapshot!" [30 seconds elapsed]
[Publishes event to "price-snapshot" queue] π
Worker Service: [Only 1 instance running] "Got it!"
[Queries 50 active markets]
[Inserts 100 rows to price_history table]
[Done β
]
API Servers 1-10: [Continue serving user requests, unaware of price snapshot]
Result:
- Exactly 1 snapshot per interval (no duplicates)
- API servers stay fast (not doing background work)
- Database load reduced by 90%
π Architecture Comparison
Current Architecture: Database Polling
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API SERVER β
β User makes trade request β
β β β
β INSERT INTO transaction_queue (...) β
β VALUES ('PENDING', {...}) β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β POSTGRESQL DATABASE β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β transaction_queue table β β
β β ββββββ¬βββββββββ¬ββββββββ¬βββββββββββββββββ β β
β β β id β status β data β created_at β β β
β β ββββββΌβββββββββΌββββββββΌβββββββββββββββββ€ β β
β β β 1 βPENDING β {...} β 2024-01-30 ... β β β
β β β 2 βPENDING β {...} β 2024-01-30 ... β β β
β β β 3 βPENDING β {...} β 2024-01-30 ... β β β
β β ββββββ΄βββββββββ΄ββββββββ΄βββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββ²ββββββββββββββββββββββββββββββββββββββββββββββ
β
β SELECT * FROM transaction_queue
β WHERE status = 'PENDING'
β LIMIT 5
β (Every 3 seconds!)
β
ββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββ
β TRANSACTION PROCESSOR (Worker) β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β setInterval(() => { β β
β β // Poll database every 3 seconds β β
β β const jobs = await db.getPending(5) β β
β β for (job of jobs) { β β
β β await processJob(job) β β
β β } β β
β β }, 3000) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β οΈ PROBLEMS:
β Database queried every 3 seconds (even when empty)
β Up to 3-second delay before job processing starts
β Multiple workers polling = Multiple redundant queries
β Database becomes bottleneck under load
β No job priorities (first-come-first-served only)
β Complex retry logic (manual implementation)
BullMQ Architecture: Event-Driven
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API SERVER β
β User makes trade request β
β β β
β await tradeQueue.add('execute-trade', {...}) β
β (Adds job to Redis - <1ms) β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β REDIS (ElastiCache) β
β In-Memory Job Queue β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Queue: "trade-execution" β β
β β ββββββββββββββββββββββββββββββββββββββ β β
β β β Job 1: PENDING (priority: 1) β β β
β β β Job 2: PENDING (priority: 1) β β β
β β β Job 3: ACTIVE (worker processing)β β β
β β ββββββββββββββββββββββββββββββββββββββ β β
β β β β
β β Queue: "price-snapshot" β β
β β ββββββββββββββββββββββββββββββββββββββ β β
β β β Job 1: PENDING (priority: 5) β β β
β β ββββββββββββββββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Pub/Sub Channels (Event Broadcasting) β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Channel: "queue:trade-execution:added" β β
β β Event: "New job available!" π β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββ
β
β INSTANT EVENT NOTIFICATION
β (0ms delay - Redis Pub/Sub)
β
βββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββ
β TRANSACTION PROCESSOR (Worker) β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β const worker = new Worker( β β
β β 'trade-execution', β β
β β async (job) => { β β
β β // Process job IMMEDIATELY! β β
β β console.log('Got job:', job.id) β β
β β await executeTradeOnBlockchain(job.data)β β
β β }, β β
β β { concurrency: 10 } // 10 parallel jobs β β
β β ) β β
β β β β
β β // Event listeners (automatic) β β
β β worker.on('completed', (job) => { β β
β β console.log('Job done!', job.id) β β
β β }) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
BENEFITS:
β
INSTANT job processing (0ms delay)
β
No database polling (Redis notifies workers)
β
Multiple workers coordinate automatically
β
Built-in job priorities (urgent jobs first)
β
Automatic retries with exponential backoff
β
Redis scales to millions of jobs/second
β
90% cost reduction (Redis cheaper than DB polling)
π Key Concepts Explained
1. Queue (Job Storage)
What it is: A Redis list that stores pending jobs in order.
Nostra Platform Example:
import { Queue } from 'bullmq';
// Create trade execution queue
const tradeQueue = new Queue('trade-execution', {
connection: { host: 'localhost', port: 6379 }
});
// User clicks "Buy 100 YES shares" on "Bitcoin reaches $100k by 2026?" market
await tradeQueue.add('execute-trade', {
userId: 'user-abc123',
marketId: 'bitcoin-100k-2026',
outcomeId: 'yes-outcome-xyz',
side: 'BUY',
shares: 100,
price: 0.65, // $0.65 per share
totalCost: 65 // 100 shares Γ $0.65 = $65 USDC
}, {
priority: 1, // Urgent! Trades are highest priority
attempts: 3, // Retry 3 times if blockchain tx fails
backoff: {
type: 'exponential',
delay: 2000 // Wait 2s, then 4s, then 8s between retries
}
});
console.log('Trade queued! User sees: "Trade pending..." β³');
// Output: Job added instantly (<1ms)
// User's browser receives immediate response
// Worker picks up job and executes on blockchain
2. Worker (Job Processor)
What it is: A background process that listens for jobs and processes them.
Nostra Platform Example:
import { Worker } from 'bullmq';
import { executeTradeOnBlockchain } from '../services/TradeExecutionService';
import { websocketService } from '../services/WebSocketService';
// Create trade execution worker
const tradeWorker = new Worker('trade-execution', async (job) => {
console.log(`β‘ Processing trade ${job.id} for user ${job.data.userId}`);
console.log(` Market: ${job.data.marketId}`);
console.log(` Action: ${job.data.side} ${job.data.shares} shares @ $${job.data.price}`);
// Step 1: Prepare signed orders
await job.updateProgress(20);
const signedOrders = await prepareOrders(job.data);
// Step 2: Execute on BSC Testnet blockchain
await job.updateProgress(50);
const txHash = await executeTradeOnBlockchain(signedOrders);
console.log(` TX broadcasted: ${txHash}`);
// Step 3: Wait for confirmation
await job.updateProgress(75);
const receipt = await waitForConfirmation(txHash);
// Step 4: Update database
await job.updateProgress(90);
await updateTradeInDatabase(job.data, receipt);
// Done!
await job.updateProgress(100);
return {
txHash,
shares: job.data.shares,
price: job.data.price,
totalCost: job.data.totalCost
};
}, {
connection: { host: 'localhost', port: 6379 },
concurrency: 10 // Process 10 trades simultaneously
});
// Listen to events
tradeWorker.on('completed', (job, result) => {
console.log(`β
Trade ${job.id} executed! TX: ${result.txHash}`);
// Notify user via WebSocket
websocketService.sendToUser(job.data.userId, {
type: 'TRADE_COMPLETED',
tradeId: job.id,
txHash: result.txHash,
message: `Successfully bought ${result.shares} shares for $${result.totalCost}`
});
// User sees: "Trade executed! β
" in their browser
});
tradeWorker.on('failed', (job, error) => {
console.error(`β Trade ${job.id} failed:`, error.message);
// Notify user of failure
websocketService.sendToUser(job.data.userId, {
type: 'TRADE_FAILED',
tradeId: job.id,
error: error.message,
message: 'Trade failed. Your funds have not been spent.'
});
// User sees: "Trade failed β" with error details
});
tradeWorker.on('progress', (job, progress) => {
console.log(`π Trade ${job.id}: ${progress}% complete`);
// Send real-time progress to user
websocketService.sendToUser(job.data.userId, {
type: 'TRADE_PROGRESS',
tradeId: job.id,
progress,
message: progress === 50 ? 'Broadcasting to blockchain...' :
progress === 75 ? 'Waiting for confirmation...' :
progress === 90 ? 'Updating balances...' : ''
});
// User sees progress bar updating in real-time
});
3. Broadcaster (Event Publisher)
What it is: Component that publishes events to Redis when something happens.
How it works: BullMQ automatically broadcasts events when jobs are added, completed, or failed.
Example (Automatic - No code needed):
// When you add a job, BullMQ automatically broadcasts:
await tradeQueue.add('execute-trade', { ... });
// Redis Pub/Sub: "queue:trade-execution:added" event
// When worker completes job, BullMQ automatically broadcasts:
return result;
// Redis Pub/Sub: "queue:trade-execution:completed" event
Custom Broadcasting (When needed for cross-service communication):
// Worker needs to notify API servers about trade completion
import Redis from 'ioredis';
const redis = new Redis();
tradeWorker.on('completed', (job, result) => {
// Broadcast to all API servers (10 instances)
redis.publish('nostra:trade:completed', JSON.stringify({
userId: job.data.userId,
marketId: job.data.marketId,
outcomeId: job.data.outcomeId,
tradeId: job.id,
txHash: result.txHash,
shares: result.shares,
price: result.price
}));
// All 10 API servers receive this event
// Each server checks if user is connected to their WebSocket
// Only the server with active WebSocket connection sends notification
});
4. Listener (Event Subscriber)
What it is: Component that listens for events from Redis Pub/Sub.
How it works: Workers automatically listen for job events. You only need to define handlers.
Example (Automatic listening):
// Workers automatically listen for new jobs
const worker = new Worker('trade-execution', async (job) => {
// This runs automatically when a job is added!
await processJob(job);
});
// You just define event handlers
worker.on('completed', (job) => {
console.log('Job completed!');
});
Custom Listening (API servers listen for trade completions):
// API Server (packages/api/src/services/WebSocketService.ts)
import Redis from 'ioredis';
const subscriber = new Redis();
subscriber.subscribe('nostra:trade:completed');
subscriber.on('message', (channel, message) => {
const trade = JSON.parse(message);
console.log(`π‘ Trade completed event received: ${trade.tradeId}`);
// Check if this user is connected to THIS API instance
const userSocket = websocketService.getConnection(trade.userId);
if (userSocket) {
// User is connected to THIS server - send notification!
userSocket.send(JSON.stringify({
type: 'TRADE_COMPLETED',
tradeId: trade.tradeId,
txHash: trade.txHash,
market: trade.marketId,
outcome: trade.outcomeId,
shares: trade.shares,
price: trade.price,
message: `Trade executed! TX: ${trade.txHash.slice(0, 10)}...`,
explorerUrl: `https://testnet.bscscan.com/tx/${trade.txHash}`
}));
console.log(`β
Notified user ${trade.userId} on this server`);
// User's browser receives WebSocket message
// UI updates: "Trade executed! β
" with transaction link
} else {
// User not connected to this server (connected to another API instance)
console.log(`βοΈ User ${trade.userId} not on this server, skipping`);
}
});
// Real-world scenario:
// - User connected to API Server #3
// - Trade executes in Worker Service
// - Worker broadcasts to Redis channel
// - All 10 API servers receive event
// - API Server #3 has user's WebSocket β sends notification β
// - API Servers #1, #2, #4-10 don't have user β skip βοΈ
π¬ Complete Trade Execution Flow: Current vs BullMQ
Current Flow (Database Polling) β
User Browser API Server Database Transaction Processor
β β β β
β "Buy 100 YES shares" β β β
βββββββββββββββββββββββββββΊβ β β
β β INSERT INTO β β
β β transaction_queue β β
β βββββββββββββββββββββββββΊβ β
β β β β
β HTTP 200 OK β β β
ββββββββββββββββββββββββββββ€ β β
β "Trade pending..." β β β
β β β β
β [User waits...] β β [Polling every 3s] β
β β³ β β "Any pending jobs?" β
β β ββββββββββββββββββββββββββββββ€
β β β SELECT * WHERE β
β β β status='PENDING' β
β β βββββββββββββββββββββββββββββΊβ
β β β No results β
β β β β
β [3 seconds pass...] β β β
β β³ β β "Any pending jobs?" β
β β ββββββββββββββββββββββββββββββ€
β β β SELECT * WHERE... β
β β βββββββββββββββββββββββββββββΊβ
β β β Found 1 job! (finally) β
β β β β
β β β [Execute on blockchain] β
β β β [Wait 2-3 seconds] β
β β β TX: 0xabc123... β
β
β β β β
β β ββββββββββββββββββββββββββββββ€
β β β UPDATE status=CONFIRMED β
β ββββββββββββββββββββββββββ€ β
β β [Manual query] β β
β β β β
β [Still waiting...] β β β
β β³ β β β
β β β β
Total time: ~5-6 seconds (3s polling delay + 2-3s blockchain)
User experience: "Slow... is it working?" π
BullMQ Flow (Event-Driven) β
User Browser API Server #3 Redis Queue Worker Service Blockchain
β β β β β
β "Buy 100 YES" β β β β
β shares β β β β
βββββββββββββββββββββββΊβ β β β
β β queue.add() β β β
β β (<1ms) β β β
β βββββββββββββββββββββββΊβ β β
β β β π NEW JOB! β β
β HTTP 200 OK β β (Instant notify) β β
ββββββββββββββββββββββββ€ βββββββββββββββββββββββΊβ β
β "Executing..." β β β Execute trade β
β β β βββββββββββββββββββββΊβ
β β β β Sign TX β
β WebSocket: 20% β β β Broadcast β
ββββββββββββββββββββββββΌβββββββββββββββββββββββΌβββββββββββββββββββββββ€ (2-3 seconds) β
β "Preparing..." β β β β
β β β β β
β WebSocket: 50% β β β Waiting for TX β
ββββββββββββββββββββββββΌβββββββββββββββββββββββΌβββββββββββββββββββββββ€ β
β "Broadcasting..." β β β β
β β β β β
β β β β Confirmed! β
β
β β β ββββββββββββββββββββββ€
β β β β TX: 0xabc123... β
β β β β β
β β Redis Pub/Sub β β Broadcast event β
β β "trade:completed" β β to all servers β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββ€ β
β β (Instant!) β β β
β β β β β
β WebSocket: 100% β β β β
ββββββββββββββββββββββββ€ β β β
β "Trade executed!" β β β β
β TX Link: 0xabc... β β β β
β β
β β β β
Total time: ~2-3 seconds (just blockchain time, 0ms queue delay)
User experience: "Fast! I can see real-time progress!" π
π§ Our 6 Background Jobs: BullMQ Migration Examples
Job 1: Price Snapshot Job πΈ
Current Implementation (node-cron):
// api/src/jobs/priceSnapshotJob.ts
import cron from 'node-cron';
let cronJob = null;
export function startPriceSnapshotJob() {
cronJob = cron.schedule('*/30 * * * * *', async () => {
console.log('πΈ Capturing price snapshots...');
await PriceHistoryService.captureAllSnapshots();
});
}
// Problems:
// β Runs in API process (competes with HTTP requests)
// β If API scales to 10 instances, runs 10 times
// β No retry logic
// β Hard to monitor
BullMQ Implementation:
// packages/shared/src/queue/priceSnapshot.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
import { PriceHistoryService } from '../services/PriceHistoryService';
// Define Queue
export const priceSnapshotQueue = new Queue('price-snapshot', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100, // Keep last 100 completed
removeOnFail: 500 // Keep last 500 failed for debugging
}
});
// Schedule repeating job (runs once, not per instance)
export async function schedulePriceSnapshots() {
await priceSnapshotQueue.add(
'capture-snapshots',
{},
{
repeat: {
pattern: '*/30 * * * * *' // Every 30 seconds
},
priority: 5 // Medium priority
}
);
console.log('β
Price snapshot job scheduled (every 30s)');
}
// Define Worker (only runs in worker service)
export const priceSnapshotWorker = new Worker(
'price-snapshot',
async (job) => {
console.log(`πΈ [${job.id}] Capturing price snapshots...`);
const startTime = Date.now();
const result = await PriceHistoryService.captureAllSnapshots();
const duration = Date.now() - startTime;
console.log(`β
[${job.id}] Captured ${result.count} snapshots in ${duration}ms`);
return result;
},
{
connection,
concurrency: 1 // Only 1 snapshot job at a time
}
);
// Event listeners
priceSnapshotWorker.on('completed', (job, result) => {
console.log(`β
Price snapshot completed: ${result.count} outcomes`);
});
priceSnapshotWorker.on('failed', (job, error) => {
console.error(`β Price snapshot failed:`, error.message);
// Alert monitoring system
});
// Benefits:
// β
Runs ONLY in worker service (not in API)
// β
Only 1 instance runs, even with 10 API servers
// β
Automatic retries (3 attempts)
// β
Progress tracking
// β
Built-in monitoring
Job 2: Blockchain Sync β°
Current Implementation (setInterval):
// api/src/services/SyncService.ts
startPeriodicSync(intervalMs: number = 30000): NodeJS.Timeout {
console.log('β° Starting periodic blockchain sync...');
return setInterval(async () => {
await this.syncFromBlockchain();
}, intervalMs);
}
// Problems:
// β Runs in API process
// β No error handling
// β Can't prioritize urgent syncs
// β No visibility into sync status
BullMQ Implementation:
// packages/shared/src/queue/blockchainSync.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
import { getSyncService } from '../services/SyncService';
// Define Queue
export const blockchainSyncQueue = new Queue('blockchain-sync', {
connection
});
// Schedule periodic sync
export async function scheduleBlockchainSync() {
await blockchainSyncQueue.add(
'sync-blockchain',
{},
{
repeat: { pattern: '*/30 * * * * *' }, // Every 30 seconds
priority: 2 // High priority (after trades)
}
);
console.log('β
Blockchain sync scheduled (every 30s)');
}
// Worker
export const blockchainSyncWorker = new Worker(
'blockchain-sync',
async (job) => {
console.log(`β° [${job.id}] Syncing blockchain state...`);
const syncService = getSyncService();
const result = await syncService.syncFromBlockchain();
console.log(`β
[${job.id}] Synced ${result.newBlocks} blocks, ${result.newTrades} trades`);
return result;
},
{
connection,
concurrency: 1 // Only sync one at a time
}
);
// Allow on-demand urgent syncs
export async function triggerUrgentSync() {
await blockchainSyncQueue.add(
'urgent-sync',
{},
{ priority: 1 } // Highest priority - process immediately
);
}
blockchainSyncWorker.on('completed', (job, result) => {
console.log(`β
Blockchain sync: ${result.newBlocks} blocks, ${result.newTrades} trades`);
// If critical updates, broadcast to API servers
if (result.newTrades > 0) {
redis.publish('blockchain:updated', JSON.stringify(result));
}
});
blockchainSyncWorker.on('failed', (job, error) => {
console.error(`β Blockchain sync failed:`, error.message);
// Critical failure - alert immediately
if (job.attemptsMade >= 3) {
alertMonitoring('CRITICAL: Blockchain sync failing', error);
}
});
// Benefits:
// β
Separated from API
// β
Can trigger urgent syncs
// β
Automatic retries
// β
Broadcasts updates to API servers
Job 3: Transaction Processor β‘
Current Implementation (setInterval polling):
// api/src/services/TransactionProcessor.ts
start(): void {
this.intervalId = setInterval(() => {
this.processQueue();
}, 3000); // Check every 3 seconds
}
private async processQueue(): Promise<void> {
const pending = await transactionQueueRepository.getPending(5);
for (const tx of pending) {
await this.processTransaction(tx);
}
}
// Problems:
// β Polls database every 3 seconds (wasted queries)
// β Up to 3 second delay before processing
// β Can't handle burst traffic (only 5 at a time)
// β Complex state management in database
BullMQ Implementation:
// packages/shared/src/queue/tradeExecution.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
import { executeTradeOnBlockchain } from '../services/TradeExecutionService';
// Define Queue
export const tradeExecutionQueue = new Queue('trade-execution', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
removeOnFail: 5000
}
});
// Worker
export const tradeExecutionWorker = new Worker(
'trade-execution',
async (job) => {
console.log(`β‘ [${job.id}] Executing trade for user ${job.data.userId}...`);
const { orderPairs, outcomeId, userId } = job.data;
// Update progress
await job.updateProgress(10); // Preparing transaction
const result = await executeTradeOnBlockchain(orderPairs, outcomeId);
await job.updateProgress(100); // Complete
console.log(`β
[${job.id}] Trade executed: ${result.txHash}`);
return result;
},
{
connection,
concurrency: 10 // Process 10 trades in parallel
}
);
// Event listeners
tradeExecutionWorker.on('completed', (job, result) => {
console.log(`β
Trade completed: ${result.txHash}`);
// Broadcast to API servers (for WebSocket notifications)
redis.publish('trade:completed', JSON.stringify({
userId: job.data.userId,
tradeId: job.id,
txHash: result.txHash,
outcomeId: job.data.outcomeId
}));
// Trigger immediate price snapshot for this outcome
priceSnapshotQueue.add('capture-snapshot', {
outcomeId: job.data.outcomeId,
urgent: true
}, {
priority: 1 // Process immediately
});
});
tradeExecutionWorker.on('failed', (job, error) => {
console.error(`β Trade failed:`, error.message);
// Notify user via WebSocket
redis.publish('trade:failed', JSON.stringify({
userId: job.data.userId,
tradeId: job.id,
error: error.message
}));
});
tradeExecutionWorker.on('progress', (job, progress) => {
console.log(`π Trade ${job.id}: ${progress}% complete`);
// Send progress update to user
redis.publish('trade:progress', JSON.stringify({
userId: job.data.userId,
tradeId: job.id,
progress
}));
});
// API endpoint usage
// packages/api/src/routes/trade.ts
import { tradeExecutionQueue } from '@nostra/shared/queue';
router.post('/execute', async (req, res) => {
const { orderPairs, outcomeId } = req.body;
// Add job to queue (INSTANT response to user)
const job = await tradeExecutionQueue.add('execute-trade', {
userId: req.user.id,
orderPairs,
outcomeId
}, {
priority: 1 // Trades are highest priority
});
// Return immediately (don't wait for blockchain)
res.json({
success: true,
jobId: job.id,
message: 'Trade queued for execution'
});
// User receives updates via WebSocket as job progresses
});
// Benefits:
// β
INSTANT API response (job queued in <1ms)
// β
No database polling
// β
10 parallel trades (not 5 sequential)
// β
Real-time progress updates
// β
Automatic retries
Job 4: Batch Processor π
Current Implementation (setInterval polling):
// api/src/services/BatchProcessor.ts
public start() {
this.interval = setInterval(() => this.processQueue(), 5000);
}
private async processQueue() {
const job = await prisma.batchJob.findFirst({
where: { status: 'PENDING' },
orderBy: { createdAt: 'asc' }
});
if (job) {
await this.processMarketCreation(job);
}
}
// Problems:
// β Polls every 5 seconds
// β Only processes 1 job at a time
// β No progress tracking
// β Hard to monitor batch status
BullMQ Implementation:
// packages/shared/src/queue/marketCreation.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
import { createMarketOnBlockchain } from '../services/MarketCreationService';
// Define Queue
export const marketCreationQueue = new Queue('market-creation', {
connection
});
// Worker
export const marketCreationWorker = new Worker(
'market-creation',
async (job) => {
console.log(`π [${job.id}] Creating market batch...`);
const { name, outcomes, categoryId, imageUrl } = job.data;
const totalMarkets = outcomes.length;
await job.updateProgress(0);
const results = [];
for (let i = 0; i < outcomes.length; i++) {
const outcome = outcomes[i];
// Create market on blockchain
const result = await createMarketOnBlockchain({
name,
outcome,
categoryId,
imageUrl
});
results.push(result);
// Update progress
const progress = Math.round(((i + 1) / totalMarkets) * 100);
await job.updateProgress(progress);
console.log(`π Progress: ${i + 1}/${totalMarkets} markets created`);
}
return {
totalCreated: results.length,
markets: results
};
},
{
connection,
concurrency: 3 // Process 3 batch jobs in parallel
}
);
marketCreationWorker.on('progress', (job, progress) => {
console.log(`π Batch ${job.id}: ${progress}% complete`);
// Broadcast progress to admin dashboard
redis.publish('batch:progress', JSON.stringify({
batchId: job.id,
progress
}));
});
marketCreationWorker.on('completed', (job, result) => {
console.log(`β
Batch completed: ${result.totalCreated} markets created`);
// Notify admin
redis.publish('batch:completed', JSON.stringify({
batchId: job.id,
result
}));
});
// API endpoint usage
// packages/api/src/routes/batch.ts
router.post('/create-markets', async (req, res) => {
const { name, outcomes, categoryId, imageUrl } = req.body;
// Add job to queue
const job = await marketCreationQueue.add('create-market-batch', {
name,
outcomes,
categoryId,
imageUrl
}, {
priority: 7 // Low priority (not urgent)
});
res.json({
success: true,
batchId: job.id,
message: 'Batch creation started'
});
});
// Benefits:
// β
Real-time progress updates (shown in admin UI)
// β
Multiple batches in parallel
// β
No database polling
// β
Better monitoring
Job 5: Cleanup Job π§Ή
Current Implementation (node-cron):
// api/src/jobs/priceSnapshotJob.ts
let cleanupJob = null;
export function startCleanupJob(daysToKeep: number = 90) {
cleanupJob = cron.schedule('0 2 * * 0', async () => {
console.log('π§Ή Cleaning up old price history data...');
await PriceHistoryService.cleanupOldData(daysToKeep);
});
}
// Problems:
// β Runs in API process
// β No monitoring of cleanup progress
// β Can't see what was deleted
BullMQ Implementation:
// packages/shared/src/queue/cleanup.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
import { PriceHistoryService } from '../services/PriceHistoryService';
// Define Queue
export const cleanupQueue = new Queue('cleanup', {
connection
});
// Schedule weekly cleanup
export async function scheduleCleanup() {
await cleanupQueue.add(
'cleanup-old-data',
{ daysToKeep: 90 },
{
repeat: {
pattern: '0 2 * * 0' // Sundays at 2 AM
},
priority: 10 // Lowest priority
}
);
console.log('β
Cleanup job scheduled (weekly, Sundays at 2 AM)');
}
// Worker
export const cleanupWorker = new Worker(
'cleanup',
async (job) => {
console.log(`π§Ή [${job.id}] Starting cleanup...`);
const { daysToKeep } = job.data;
const startTime = Date.now();
// Cleanup old price history
await job.updateProgress(30);
const priceHistoryDeleted = await PriceHistoryService.cleanupOldData(daysToKeep);
// Cleanup old transaction queue
await job.updateProgress(60);
const transactionQueueDeleted = await transactionQueueRepository.cleanupOld(30);
// Cleanup old batch jobs
await job.updateProgress(90);
const batchJobsDeleted = await batchJobRepository.cleanupOld(30);
const duration = Date.now() - startTime;
return {
priceHistoryDeleted,
transactionQueueDeleted,
batchJobsDeleted,
duration
};
},
{
connection,
concurrency: 1
}
);
cleanupWorker.on('completed', (job, result) => {
console.log('β
Cleanup completed:');
console.log(` - Price history: ${result.priceHistoryDeleted} records deleted`);
console.log(` - Transaction queue: ${result.transactionQueueDeleted} records deleted`);
console.log(` - Batch jobs: ${result.batchJobsDeleted} records deleted`);
console.log(` - Duration: ${result.duration}ms`);
// Alert monitoring system with cleanup stats
alertMonitoring('Cleanup completed', result);
});
// Benefits:
// β
Detailed cleanup stats
// β
Progress tracking
// β
Monitoring alerts
// β
Separated from API
Job 6: WebSocket Heartbeat π
Current Implementation (setInterval):
// api/src/services/WebSocketService.ts
this.heartbeatInterval = setInterval(() => {
this.clients.forEach((client) => {
if (!client.isAlive) client.terminate();
client.isAlive = false;
client.ping();
});
}, 30000);
// This should STAY as-is!
// β
WebSocket heartbeat needs to run in API process
// β
Checks connection status of clients connected to THIS instance
// β
Not suitable for job queue (needs to be per-instance)
Recommendation: KEEP AS-IS β
WebSocket heartbeat should remain in the API process because:
- Each API instance needs to ping its own connected clients
- Not a background job (it's connection management)
- Latency-sensitive (needs immediate response)
- Instance-specific (not shared across workers)
π Performance Comparison
Current System (Database Polling)
| Job | Interval | DB Queries/Hour | Delay | Issues |
|---|---|---|---|---|
| Price Snapshot | 30s | 120 | 0-30s | Runs 10x with 10 API instances |
| Blockchain Sync | 30s | 120 | 0-30s | High DB load |
| Transaction Processor | 3s | 1,200 | 0-3s | Constant polling |
| Batch Processor | 5s | 720 | 0-5s | Slow progress tracking |
| TOTAL | - | 2,160/hour | - | Database overload |
At scale (10 API instances):
- Total DB queries: 21,600/hour (6 queries/second!)
- Database IOPS cost: ~$50/month
- Average job delay: 2 seconds
BullMQ System (Event-Driven)
| Job | Trigger | Redis Operations | Delay | Benefits |
|---|---|---|---|---|
| Price Snapshot | Event | 2/execution | 0ms | Instant processing |
| Blockchain Sync | Event | 2/execution | 0ms | Only when needed |
| Transaction Processor | Event | 2/execution | 0ms | Real-time |
| Batch Processor | Event | 2/execution | 0ms | Progress tracking |
| TOTAL | - | ~240/hour | - | 90% reduction |
At scale (10 API instances + 1 worker):
- Total operations: 240/hour (0.07 ops/second)
- Redis cost: ~$13/month
- Average job delay: 0ms (instant)
- Savings: $37/month + 10x better performance
π Migration Strategy
Phase 1: Setup Infrastructure (1 hour)
# Install BullMQ
yarn workspace @nostra/shared add bullmq ioredis
# Add Redis to environment
echo "REDIS_URL=redis://localhost:6379" >> .env
# Start Redis locally
docker run -d -p 6379:6379 redis:7-alpine
Phase 2: Create Shared Queue Infrastructure (2 hours)
// packages/shared/src/queue/connection.ts
import Redis from 'ioredis';
export const connection = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: false
});
// packages/shared/src/queue/index.ts
export * from './priceSnapshot.queue';
export * from './blockchainSync.queue';
export * from './tradeExecution.queue';
export * from './marketCreation.queue';
export * from './cleanup.queue';
Phase 3: Migrate One Job (Proof of Concept) (2 hours)
Start with Transaction Processor (highest impact):
// packages/shared/src/queue/tradeExecution.queue.ts
import { Queue, Worker } from 'bullmq';
import { connection } from './connection';
export const tradeExecutionQueue = new Queue('trade-execution', {
connection
});
export const tradeExecutionWorker = new Worker(
'trade-execution',
async (job) => {
// Migrate processTransaction logic here
await executeTradeOnBlockchain(job.data);
},
{ connection, concurrency: 10 }
);
// packages/worker/src/index.ts
import { tradeExecutionWorker } from '@nostra/shared/queue';
console.log('β
Trade execution worker started');
// Test it works, then migrate other jobs
Phase 4: Migrate Remaining Jobs (4-6 hours)
One by one, migrate:
- β Transaction Processor (done in Phase 3)
- Price Snapshot Job
- Blockchain Sync
- Batch Processor
- Cleanup Job
Phase 5: Add Monitoring Dashboard (1 hour)
# Install Bull Board (monitoring UI)
yarn add @bull-board/express @bull-board/api
# Access at http://localhost:4001/admin/queues
// packages/api/src/routes/admin.ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [
new BullMQAdapter(tradeExecutionQueue),
new BullMQAdapter(priceSnapshotQueue),
new BullMQAdapter(blockchainSyncQueue),
new BullMQAdapter(marketCreationQueue),
new BullMQAdapter(cleanupQueue)
],
serverAdapter
});
app.use('/admin/queues', serverAdapter.getRouter());
π Learning Resources
Official Documentation
- BullMQ Docs: https://docs.bullmq.io/
- Redis Documentation: https://redis.io/docs/
- Bull Board (UI): https://github.com/felixmosh/bull-board
Video Tutorials
- BullMQ Crash Course: https://www.youtube.com/watch?v=oUJbuFMyBDk
- Redis Pub/Sub Explained: https://www.youtube.com/watch?v=Gho0ojr-WGo
Example Projects
- BullMQ Examples: https://github.com/taskforcesh/bullmq/tree/master/docs/gitbook/patterns
- Real-world implementations: https://github.com/topics/bullmq
π Summary
What is BullMQ?
β Redis-based job queue system β Event-driven (no database polling) β Industry standard (used by Stripe, Shopify, Uber)
Why Do We Need It?
β 90% reduction in database queries β Instant job processing (0ms delay) β Better scalability (millions of jobs/second) β Built-in monitoring and debugging
What Are Broadcaster/Listener?
β Automatic in BullMQ (no manual setup needed) β Redis Pub/Sub for worker coordination β Only customize for cross-service communication
Migration Effort
β±οΈ 10-15 hours total π ROI: ~$40/month savings + 10x better performance π― Break-even: Immediately (better UX + reliability)
Comments
@linked0 β 2026-02-02T11:18:39Z
This is exactly what we've been looking for. Thanks! Given the performance benefits, it looks like we need to apply this as soon as possible.
@Abdulkarim4u β 2026-02-02T11:21:57Z
yes exactly, my old company thats how we used to do it from scratch for both testnet and production, so there would be no need to redo it when launching.