Social media platforms don't give you webhooks. Instagram won't ping your server when someone comments. TikTok won't notify you when a creator posts.
So you build your own.
I built an event bus that polls social media APIs and converts changes into events. New post? Event. New comment? Event. Follower count changed by more than 5%? Event. Then any downstream system can subscribe — Discord bots, email senders, dashboards, CRMs.
It turned 10 separate "check social media" scripts into one system.
Architecture
Poller (cron jobs)
│
├── Check profiles every 30 minutes
├── Check posts every 15 minutes
├── Check comments every hour
│
↓ Detect changes (diff against last known state)
│
Event Bus (in-process EventEmitter or Redis Pub/Sub)
│
├── → Discord notifier
├── → Email sender
├── → Database logger
├── → Slack alerter
└── → Webhook forwarder (POST to any URL)
The pollers detect changes. The event bus routes them. The handlers do whatever you want. Completely decoupled.
The Stack
- Node.js – runtime
- SociaVault API – data source
- EventEmitter (built-in) – event bus for single-process; Redis Pub/Sub for multi-process
- better-sqlite3 – state tracking
- node-cron – polling schedule
Setup
mkdir social-event-bus && cd social-event-bus
npm init -y
npm install axios better-sqlite3 node-cron dotenv
Step 1: The State Store
To detect changes, you need to know what things looked like last time you checked.
// state.js
const Database = require('better-sqlite3');
const db = new Database('./state.db');
db.exec(`
CREATE TABLE IF NOT EXISTS known_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
const getState = db.prepare('SELECT value FROM known_state WHERE key = ?');
const setState = db.prepare(`
INSERT INTO known_state (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`);
module.exports = {
get: (key) => {
const row = getState.get(key);
return row ? JSON.parse(row.value) : null;
},
set: (key, value) => {
setState.run(key, JSON.stringify(value));
},
};
Step 2: The Event Bus
// bus.js
const { EventEmitter } = require('events');
class SocialEventBus extends EventEmitter {
emit(eventType, payload) {
const event = {
type: eventType,
timestamp: new Date().toISOString(),
...payload,
};
// Log every event
console.log(`[EVENT] ${eventType} — ${payload.platform}/@${payload.username || 'unknown'}`);
// Emit both the specific event and a wildcard
super.emit(eventType, event);
super.emit('*', event);
return true;
}
}
// Singleton
const bus = new SocialEventBus();
module.exports = bus;
Event types we'll generate:
| Event | Trigger |
|---|---|
new_post |
Creator published a new post/video |
post_milestone |
A post crossed a view/like threshold |
follower_change |
Follower count changed significantly (±5%) |
new_comment |
New comment on a tracked post |
engagement_spike |
Post engagement rate is 3x+ above creator's average |
profile_updated |
Bio, name, or profile pic changed |
Step 3: The Pollers
Each poller fetches current data, diffs against stored state, and emits events for any changes.
// pollers/profile-poller.js
const axios = require('axios');
const state = require('../state');
const bus = require('../bus');
const api = axios.create({
baseURL: 'https://api.sociavault.com/v1/scrape',
headers: { 'x-api-key': process.env.SOCIAVAULT_API_KEY },
});
async function pollProfile(platform, username) {
const endpoint = platform === 'instagram'
? `/instagram/profile?username=${username}`
: `/tiktok/profile?username=${username}`;
try {
const { data: res } = await api.get(endpoint);
const profile = res.data || res;
const key = `profile:${platform}:${username}`;
const previous = state.get(key);
const current = {
followers: profile.followersCount || profile.followerCount || 0,
following: profile.followingCount || 0,
posts: profile.postsCount || profile.videoCount || 0,
bio: profile.bio || profile.signature || '',
displayName: profile.fullName || profile.nickname || '',
};
if (previous) {
// Check for follower changes (±5% or ±1000)
const followerDelta = current.followers - previous.followers;
const followerPercent = previous.followers > 0
? Math.abs(followerDelta / previous.followers) * 100
: 0;
if (followerPercent >= 5 || Math.abs(followerDelta) >= 1000) {
bus.emit('follower_change', {
platform,
username,
previous: previous.followers,
current: current.followers,
delta: followerDelta,
percentChange: parseFloat(followerPercent.toFixed(1)),
});
}
// Check for new posts
if (current.posts > previous.posts) {
bus.emit('new_post', {
platform,
username,
previousCount: previous.posts,
currentCount: current.posts,
newPosts: current.posts - previous.posts,
});
}
// Check for bio changes
if (current.bio !== previous.bio) {
bus.emit('profile_updated', {
platform,
username,
field: 'bio',
old: previous.bio,
new: current.bio,
});
}
}
state.set(key, current);
} catch (err) {
console.error(`Poll failed for ${platform}/@${username}: ${err.message}`);
}
}
module.exports = { pollProfile };
// pollers/post-poller.js
const axios = require('axios');
const state = require('../state');
const bus = require('../bus');
const api = axios.create({
baseURL: 'https://api.sociavault.com/v1/scrape',
headers: { 'x-api-key': process.env.SOCIAVAULT_API_KEY },
});
async function pollPosts(platform, username) {
const endpoint = platform === 'instagram'
? `/instagram/posts?username=${username}&limit=5`
: `/tiktok/profile-videos?username=${username}&limit=5`;
try {
const { data: res } = await api.get(endpoint);
const posts = res.data || res.posts || [];
for (const post of posts) {
const postId = post.id || post.shortcode || post.videoId;
if (!postId) continue;
const key = `post:${platform}:${postId}`;
const previous = state.get(key);
const current = {
likes: post.likesCount || post.diggCount || 0,
comments: post.commentsCount || post.commentCount || 0,
views: post.viewCount || post.playCount || null,
shares: post.shareCount || null,
};
if (previous) {
// Check for engagement spike
const likeGrowth = previous.likes > 0
? current.likes / previous.likes
: 0;
if (likeGrowth >= 3) {
bus.emit('engagement_spike', {
platform,
username,
postId,
metric: 'likes',
previous: previous.likes,
current: current.likes,
multiplier: parseFloat(likeGrowth.toFixed(1)),
});
}
// Check for view milestones (10K, 100K, 1M)
const milestones = [10000, 100000, 1000000, 10000000];
if (current.views) {
for (const milestone of milestones) {
if (previous.views < milestone && current.views >= milestone) {
bus.emit('post_milestone', {
platform,
username,
postId,
milestone,
currentViews: current.views,
});
}
}
}
}
state.set(key, current);
}
} catch (err) {
console.error(`Post poll failed for ${platform}/@${username}: ${err.message}`);
}
}
module.exports = { pollPosts };
Step 4: The Handlers
This is where you plug in whatever actions you want:
// handlers/discord.js
const axios = require('axios');
const bus = require('../bus');
const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK_URL;
bus.on('new_post', async (event) => {
if (!DISCORD_WEBHOOK) return;
await axios.post(DISCORD_WEBHOOK, {
content: `🆕 **@${event.username}** posted ${event.newPosts} new ${event.newPosts === 1 ? 'post' : 'posts'} on ${event.platform}!`,
});
});
bus.on('engagement_spike', async (event) => {
if (!DISCORD_WEBHOOK) return;
await axios.post(DISCORD_WEBHOOK, {
content: `🔥 **Engagement spike!** @${event.username}'s post is getting ${event.multiplier}x normal likes on ${event.platform}`,
});
});
bus.on('follower_change', async (event) => {
if (!DISCORD_WEBHOOK) return;
const direction = event.delta > 0 ? '📈' : '📉';
const sign = event.delta > 0 ? '+' : '';
await axios.post(DISCORD_WEBHOOK, {
content: `${direction} **@${event.username}** ${sign}${event.delta.toLocaleString()} followers (${event.percentChange}%) on ${event.platform}`,
});
});
// handlers/webhook-forwarder.js
const axios = require('axios');
const bus = require('../bus');
// Forward all events to an external URL (your own API, Zapier, n8n, etc.)
const WEBHOOK_URL = process.env.FORWARD_WEBHOOK_URL;
bus.on('*', async (event) => {
if (!WEBHOOK_URL) return;
try {
await axios.post(WEBHOOK_URL, event, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
});
} catch (err) {
console.error(`Webhook forward failed: ${err.message}`);
}
});
Step 5: Main Entry Point
// index.js
require('dotenv').config();
const cron = require('node-cron');
const { pollProfile } = require('./pollers/profile-poller');
const { pollPosts } = require('./pollers/post-poller');
// Load handlers (they self-register on the bus)
require('./handlers/discord');
require('./handlers/webhook-forwarder');
// Accounts to monitor
const WATCHED = [
{ platform: 'instagram', username: 'competitor_1' },
{ platform: 'instagram', username: 'competitor_2' },
{ platform: 'tiktok', username: 'competitor_3' },
{ platform: 'tiktok', username: 'your_own_account' },
];
async function runProfilePolls() {
console.log(`[${new Date().toISOString()}] Polling profiles...`);
for (const account of WATCHED) {
await pollProfile(account.platform, account.username);
await new Promise(r => setTimeout(r, 500));
}
}
async function runPostPolls() {
console.log(`[${new Date().toISOString()}] Polling posts...`);
for (const account of WATCHED) {
await pollPosts(account.platform, account.username);
await new Promise(r => setTimeout(r, 500));
}
}
// Initial run
runProfilePolls();
runPostPolls();
// Schedule
cron.schedule('*/30 * * * *', runProfilePolls); // Profiles every 30 min
cron.schedule('*/15 * * * *', runPostPolls); // Posts every 15 min
console.log(`Social event bus started. Watching ${WATCHED.length} accounts.`);
console.log('Profile polls: every 30 minutes');
console.log('Post polls: every 15 minutes');
Why This Pattern?
Because polling scripts always start simple and end up as spaghetti. You start with one script that checks competitors and sends a Discord message. Then your boss wants Slack too. Then email. Then someone wants to log it to a spreadsheet. Then you need to check comments too, not just posts.
The event bus pattern means:
- Adding a new data source = write one poller function
- Adding a new action = write one handler function
- They don't know about each other — the poller doesn't care if Discord or Slack or email is listening
I've run this pattern for 6 months. Added 4 handlers and 2 pollers without touching existing code once.
Scaling Up
When you outgrow a single Node.js process:
- Replace
EventEmitterwith Redis Pub/Sub — pollers publish, handlers subscribe, can run on different machines - Move pollers to separate workers — one per platform
- Add a dead letter queue for failed handler deliveries
- Add a simple web UI to see recent events (Express + SSE)
But honestly, a single Node process on a $5 VPS handles 50+ accounts with room to spare.
Read the Full Guide
Build a Social Media Event Bus → SociaVault Blog
Turn social media data into real-time events with SociaVault — one API for TikTok, Instagram, YouTube, and 10+ platforms. Profiles, posts, comments, followers — all endpoints, one key.
Discussion
What's your approach to "real-time" social media monitoring when the platforms don't offer webhooks? Poll and diff like this, or a different strategy entirely?
Top comments (0)