Posting at 3 AM when your audience sleeps? That's why your engagement is trash.
Every platform has optimal posting windows. But they're different for every creator, every niche, every audience.
In this tutorial, we'll build a Best Time to Post Analyzer that:
- Analyzes engagement patterns from your top-performing content
- Identifies YOUR optimal posting windows (not generic advice)
- Generates a personalized posting schedule based on data
No more guessing. Post when your audience is actually online.
Why Generic "Best Times" Don't Work
You've seen those blog posts: "Best time to post on TikTok is 7 PM EST!"
Garbage.
A fitness influencer's audience wakes up at 5 AM. A gaming creator's audience is online at midnight. A B2B thought leader's audience engages during lunch breaks.
The only reliable data is YOUR data.
The Stack
- Node.js: Runtime
- SociaVault API: Fetch posting history and engagement
- OpenAI API: Generate insights and recommendations
Step 1: Setup
mkdir best-time-analyzer
cd best-time-analyzer
npm init -y
npm install axios openai dotenv
Create .env:
SOCIAVAULT_API_KEY=your_sociavault_key
OPENAI_API_KEY=your_openai_key
Step 2: Fetch Historical Posts
Create index.js:
require('dotenv').config();
const axios = require('axios');
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const SOCIAVAULT_BASE = 'https://api.sociavault.com';
const headers = { 'Authorization': `Bearer ${process.env.SOCIAVAULT_API_KEY}` };
async function getTikTokPosts(handle) {
console.log(`📱 Fetching TikTok posts for @${handle}...`);
try {
// Get profile first
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/tiktok/profile`, {
params: { handle },
headers
});
const followers = profileRes.data.data.followerCount || profileRes.data.data.fans;
// Get videos
const videosRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/tiktok/videos`, {
params: { handle, limit: 50 },
headers
});
return videosRes.data.data.map(video => ({
platform: 'tiktok',
id: video.id,
description: video.desc || video.description,
views: video.playCount || video.stats?.playCount || 0,
likes: video.diggCount || video.stats?.diggCount || 0,
comments: video.commentCount || video.stats?.commentCount || 0,
shares: video.shareCount || video.stats?.shareCount || 0,
posted: new Date(video.createTime * 1000),
followers,
engagementRate: calculateEngagement(video, followers)
}));
} catch (error) {
console.error('TikTok error:', error.message);
return [];
}
}
async function getInstagramPosts(handle) {
console.log(`📸 Fetching Instagram posts for @${handle}...`);
try {
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/instagram/profile`, {
params: { handle },
headers
});
const followers = profileRes.data.data.follower_count || profileRes.data.data.followers;
const postsRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/instagram/posts`, {
params: { handle, limit: 50 },
headers
});
return postsRes.data.data.map(post => ({
platform: 'instagram',
id: post.id || post.pk,
description: post.caption || '',
views: post.play_count || post.video_view_count || post.like_count * 10, // Estimate for images
likes: post.like_count || post.likes || 0,
comments: post.comment_count || post.comments || 0,
posted: new Date(post.taken_at * 1000),
followers,
engagementRate: ((post.like_count + post.comment_count) / followers) * 100
}));
} catch (error) {
console.error('Instagram error:', error.message);
return [];
}
}
async function getTwitterPosts(handle) {
console.log(`🐦 Fetching Twitter posts for @${handle}...`);
try {
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/twitter/profile`, {
params: { handle },
headers
});
const followers = profileRes.data.data.followers_count || profileRes.data.data.followers;
const tweetsRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/twitter/user-tweets`, {
params: { handle, limit: 50 },
headers
});
return tweetsRes.data.data.map(tweet => ({
platform: 'twitter',
id: tweet.id || tweet.rest_id,
text: tweet.full_text || tweet.text,
views: tweet.views_count || tweet.views || 0,
likes: tweet.favorite_count || tweet.likes || 0,
retweets: tweet.retweet_count || 0,
replies: tweet.reply_count || 0,
posted: new Date(tweet.created_at),
followers,
engagementRate: ((tweet.favorite_count + tweet.retweet_count + tweet.reply_count) / followers) * 100
}));
} catch (error) {
console.error('Twitter error:', error.message);
return [];
}
}
function calculateEngagement(video, followers) {
const likes = video.diggCount || video.stats?.diggCount || 0;
const comments = video.commentCount || video.stats?.commentCount || 0;
const shares = video.shareCount || video.stats?.shareCount || 0;
return ((likes + comments + shares) / followers) * 100;
}
Step 3: Analyze Posting Patterns
function analyzePostingPatterns(posts) {
// Group posts by hour of day
const hourlyEngagement = new Array(24).fill(null).map(() => ({
posts: [],
totalEngagement: 0,
count: 0
}));
// Group posts by day of week
const dailyEngagement = {
0: { posts: [], totalEngagement: 0, count: 0, name: 'Sunday' },
1: { posts: [], totalEngagement: 0, count: 0, name: 'Monday' },
2: { posts: [], totalEngagement: 0, count: 0, name: 'Tuesday' },
3: { posts: [], totalEngagement: 0, count: 0, name: 'Wednesday' },
4: { posts: [], totalEngagement: 0, count: 0, name: 'Thursday' },
5: { posts: [], totalEngagement: 0, count: 0, name: 'Friday' },
6: { posts: [], totalEngagement: 0, count: 0, name: 'Saturday' }
};
// Group by hour + day combo
const heatmap = {};
posts.forEach(post => {
const hour = post.posted.getHours();
const day = post.posted.getDay();
const engagement = post.engagementRate;
// Hourly
hourlyEngagement[hour].posts.push(post);
hourlyEngagement[hour].totalEngagement += engagement;
hourlyEngagement[hour].count++;
// Daily
dailyEngagement[day].posts.push(post);
dailyEngagement[day].totalEngagement += engagement;
dailyEngagement[day].count++;
// Heatmap (day-hour combo)
const key = `${day}-${hour}`;
if (!heatmap[key]) {
heatmap[key] = { day, hour, totalEngagement: 0, count: 0, posts: [] };
}
heatmap[key].totalEngagement += engagement;
heatmap[key].count++;
heatmap[key].posts.push(post);
});
// Calculate averages
const hourlyAvg = hourlyEngagement.map((h, i) => ({
hour: i,
avgEngagement: h.count > 0 ? h.totalEngagement / h.count : 0,
postCount: h.count,
label: formatHour(i)
}));
const dailyAvg = Object.values(dailyEngagement).map(d => ({
day: d.name,
avgEngagement: d.count > 0 ? d.totalEngagement / d.count : 0,
postCount: d.count
}));
const heatmapAvg = Object.values(heatmap).map(h => ({
day: dailyEngagement[h.day].name,
hour: formatHour(h.hour),
avgEngagement: h.count > 0 ? h.totalEngagement / h.count : 0,
postCount: h.count
}));
return {
hourly: hourlyAvg,
daily: dailyAvg,
heatmap: heatmapAvg,
totalPosts: posts.length
};
}
function formatHour(hour) {
if (hour === 0) return '12 AM';
if (hour === 12) return '12 PM';
return hour < 12 ? `${hour} AM` : `${hour - 12} PM`;
}
Step 4: Find Optimal Windows
function findOptimalWindows(analysis) {
const { hourly, daily, heatmap } = analysis;
// Sort hours by engagement
const sortedHours = [...hourly]
.filter(h => h.postCount >= 2) // Need at least 2 posts for reliability
.sort((a, b) => b.avgEngagement - a.avgEngagement);
// Sort days by engagement
const sortedDays = [...daily]
.filter(d => d.postCount >= 3) // Need at least 3 posts
.sort((a, b) => b.avgEngagement - a.avgEngagement);
// Sort heatmap combos
const sortedCombos = [...heatmap]
.filter(h => h.postCount >= 1)
.sort((a, b) => b.avgEngagement - a.avgEngagement);
// Find best times
const bestHours = sortedHours.slice(0, 5);
const worstHours = sortedHours.slice(-3).reverse();
const bestDays = sortedDays.slice(0, 3);
const bestCombos = sortedCombos.slice(0, 10);
// Identify posting windows (clusters of good hours)
const windows = identifyWindows(sortedHours);
return {
bestHours,
worstHours,
bestDays,
bestCombos,
windows
};
}
function identifyWindows(sortedHours) {
const goodHours = sortedHours
.filter(h => h.avgEngagement > 0)
.slice(0, 8)
.map(h => h.hour)
.sort((a, b) => a - b);
const windows = [];
let currentWindow = { start: goodHours[0], end: goodHours[0] };
for (let i = 1; i < goodHours.length; i++) {
if (goodHours[i] - currentWindow.end <= 2) {
currentWindow.end = goodHours[i];
} else {
windows.push({
start: formatHour(currentWindow.start),
end: formatHour(currentWindow.end + 1),
hours: currentWindow.end - currentWindow.start + 1
});
currentWindow = { start: goodHours[i], end: goodHours[i] };
}
}
windows.push({
start: formatHour(currentWindow.start),
end: formatHour(currentWindow.end + 1),
hours: currentWindow.end - currentWindow.start + 1
});
return windows.sort((a, b) => b.hours - a.hours);
}
Step 5: Generate Smart Recommendations
async function generateRecommendations(optimal, analysis, platform) {
const prompt = `Based on this social media posting data, generate personalized posting recommendations.
Platform: ${platform}
Total posts analyzed: ${analysis.totalPosts}
Best performing hours (by engagement rate):
${optimal.bestHours.map(h => `- ${h.label}: ${h.avgEngagement.toFixed(2)}% engagement (${h.postCount} posts)`).join('\n')}
Worst performing hours:
${optimal.worstHours.map(h => `- ${h.label}: ${h.avgEngagement.toFixed(2)}% engagement`).join('\n')}
Best performing days:
${optimal.bestDays.map(d => `- ${d.day}: ${d.avgEngagement.toFixed(2)}% engagement (${d.postCount} posts)`).join('\n')}
Top day+hour combinations:
${optimal.bestCombos.slice(0, 5).map(c => `- ${c.day} at ${c.hour}: ${c.avgEngagement.toFixed(2)}% engagement`).join('\n')}
Identified posting windows:
${optimal.windows.map(w => `- ${w.start} to ${w.end} (${w.hours} hour window)`).join('\n')}
Generate:
1. A specific weekly posting schedule (days and exact times)
2. 3 key insights about this creator's audience
3. What to avoid (bad times/patterns)
4. One surprising finding from the data
Keep it actionable and specific. Format as JSON with keys: schedule, insights, avoid, surprise.`;
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
}
function generateSchedule(optimal, postsPerWeek = 7) {
const schedule = [];
const bestCombos = optimal.bestCombos.slice(0, postsPerWeek);
bestCombos.forEach((combo, i) => {
schedule.push({
day: combo.day,
time: combo.hour,
priority: i + 1,
expectedEngagement: `${combo.avgEngagement.toFixed(2)}%`
});
});
// Sort by day order
const dayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
schedule.sort((a, b) => dayOrder.indexOf(a.day) - dayOrder.indexOf(b.day));
return schedule;
}
Step 6: Display Results
function displayResults(analysis, optimal, recommendations) {
console.log('\n═══════════════════════════════════════════════════════════');
console.log('📊 BEST TIME TO POST ANALYSIS');
console.log('═══════════════════════════════════════════════════════════\n');
console.log(`📈 Analyzed ${analysis.totalPosts} posts\n`);
// Best Hours
console.log('🏆 BEST HOURS TO POST');
console.log('─────────────────────────────────────────────────────────');
optimal.bestHours.forEach((h, i) => {
const bar = '█'.repeat(Math.round(h.avgEngagement * 5));
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : ' ';
console.log(`${medal} ${h.label.padEnd(8)} ${bar} ${h.avgEngagement.toFixed(2)}% (${h.postCount} posts)`);
});
console.log('\n❌ WORST HOURS (AVOID)');
console.log('─────────────────────────────────────────────────────────');
optimal.worstHours.forEach(h => {
console.log(` ${h.label.padEnd(8)} ${h.avgEngagement.toFixed(2)}%`);
});
// Best Days
console.log('\n📅 BEST DAYS TO POST');
console.log('─────────────────────────────────────────────────────────');
optimal.bestDays.forEach((d, i) => {
const bar = '█'.repeat(Math.round(d.avgEngagement * 5));
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉';
console.log(`${medal} ${d.day.padEnd(10)} ${bar} ${d.avgEngagement.toFixed(2)}%`);
});
// Golden Windows
console.log('\n⏰ OPTIMAL POSTING WINDOWS');
console.log('─────────────────────────────────────────────────────────');
optimal.windows.forEach(w => {
console.log(` 🕐 ${w.start} - ${w.end}`);
});
// Best Combos (Heatmap top entries)
console.log('\n🔥 TOP 5 DAY + TIME COMBINATIONS');
console.log('─────────────────────────────────────────────────────────');
optimal.bestCombos.slice(0, 5).forEach((c, i) => {
console.log(`${i + 1}. ${c.day} at ${c.hour} → ${c.avgEngagement.toFixed(2)}% engagement`);
});
// AI Recommendations
if (recommendations) {
console.log('\n═══════════════════════════════════════════════════════════');
console.log('🤖 AI-POWERED RECOMMENDATIONS');
console.log('═══════════════════════════════════════════════════════════\n');
console.log('📅 RECOMMENDED WEEKLY SCHEDULE:');
console.log('─────────────────────────────────────────────────────────');
if (recommendations.schedule) {
recommendations.schedule.forEach(slot => {
console.log(` ${slot.day}: ${slot.time}`);
});
}
console.log('\n💡 KEY INSIGHTS:');
console.log('─────────────────────────────────────────────────────────');
if (recommendations.insights) {
recommendations.insights.forEach((insight, i) => {
console.log(` ${i + 1}. ${insight}`);
});
}
console.log('\n🚫 WHAT TO AVOID:');
console.log('─────────────────────────────────────────────────────────');
if (recommendations.avoid) {
recommendations.avoid.forEach(item => {
console.log(` • ${item}`);
});
}
if (recommendations.surprise) {
console.log('\n✨ SURPRISING FINDING:');
console.log('─────────────────────────────────────────────────────────');
console.log(` ${recommendations.surprise}`);
}
}
}
Step 7: Run It
async function analyzeBestTimes(handle, platform = 'tiktok') {
console.log('\n🔍 Starting analysis...\n');
// Fetch posts
let posts;
switch (platform) {
case 'tiktok':
posts = await getTikTokPosts(handle);
break;
case 'instagram':
posts = await getInstagramPosts(handle);
break;
case 'twitter':
posts = await getTwitterPosts(handle);
break;
default:
console.log('Unsupported platform');
return;
}
if (posts.length === 0) {
console.log('No posts found.');
return;
}
console.log(`✅ Found ${posts.length} posts to analyze\n`);
// Analyze patterns
const analysis = analyzePostingPatterns(posts);
// Find optimal windows
const optimal = findOptimalWindows(analysis);
// Generate AI recommendations
console.log('🤖 Generating AI recommendations...\n');
const recommendations = await generateRecommendations(optimal, analysis, platform);
// Display results
displayResults(analysis, optimal, recommendations);
// Generate downloadable schedule
const schedule = generateSchedule(optimal);
console.log('\n📋 COPY THIS SCHEDULE:');
console.log('─────────────────────────────────────────────────────────');
console.log(JSON.stringify(schedule, null, 2));
return { analysis, optimal, recommendations, schedule };
}
// Main
const handle = process.argv[2] || 'garyvee';
const platform = process.argv[3] || 'tiktok';
analyzeBestTimes(handle, platform);
Run with:
node index.js charlidamelio tiktok
node index.js garyvee instagram
node index.js elonmusk twitter
Sample Output
🔍 Starting analysis...
📱 Fetching TikTok posts for @charlidamelio...
✅ Found 50 posts to analyze
🤖 Generating AI recommendations...
═══════════════════════════════════════════════════════════
📊 BEST TIME TO POST ANALYSIS
═══════════════════════════════════════════════════════════
📈 Analyzed 50 posts
🏆 BEST HOURS TO POST
─────────────────────────────────────────────────────────
🥇 6 PM ████████████ 2.45% (8 posts)
🥈 7 PM ███████████ 2.31% (6 posts)
🥉 9 PM █████████ 1.89% (7 posts)
8 PM ████████ 1.67% (5 posts)
12 PM ███████ 1.52% (4 posts)
❌ WORST HOURS (AVOID)
─────────────────────────────────────────────────────────
3 AM 0.21%
4 AM 0.18%
5 AM 0.34%
📅 BEST DAYS TO POST
─────────────────────────────────────────────────────────
🥇 Thursday ██████████ 2.12%
🥈 Sunday █████████ 1.98%
🥉 Saturday ████████ 1.76%
⏰ OPTIMAL POSTING WINDOWS
─────────────────────────────────────────────────────────
🕐 6 PM - 10 PM
🕐 12 PM - 2 PM
🔥 TOP 5 DAY + TIME COMBINATIONS
─────────────────────────────────────────────────────────
1. Thursday at 6 PM → 2.89% engagement
2. Sunday at 7 PM → 2.67% engagement
3. Saturday at 9 PM → 2.45% engagement
4. Thursday at 7 PM → 2.34% engagement
5. Friday at 6 PM → 2.21% engagement
═══════════════════════════════════════════════════════════
🤖 AI-POWERED RECOMMENDATIONS
═══════════════════════════════════════════════════════════
📅 RECOMMENDED WEEKLY SCHEDULE:
─────────────────────────────────────────────────────────
Monday: 6 PM
Wednesday: 7 PM
Thursday: 6 PM
Saturday: 9 PM
Sunday: 7 PM
💡 KEY INSIGHTS:
─────────────────────────────────────────────────────────
1. Your audience is most active in the evening hours (6-10 PM)
2. Weekend evenings show 34% higher engagement than weekdays
3. Lunchtime (12-2 PM) is a secondary peak window
🚫 WHAT TO AVOID:
─────────────────────────────────────────────────────────
• Early morning posts (3-6 AM) - 78% lower engagement
• Monday mornings - your lowest performing time slot
• Posting during work hours on weekdays
✨ SURPRISING FINDING:
─────────────────────────────────────────────────────────
Thursday significantly outperforms Friday for engagement,
contrary to typical "TGIF" social media wisdom. Consider
Thursday as your primary posting day.
What You Just Built
Buffer, Hootsuite, and Later all charge premium for posting time optimization:
- Buffer: $15/month (basic analytics)
- Later: $25/month (best time feature)
- Hootsuite: $99/month (advanced analytics)
Your version analyzes YOUR actual data for cents per API call.
Pro Tips
- More data = better results: Analyze accounts with 50+ posts
- Account for time zones: Consider your audience's location
- Reanalyze monthly: Audience behavior changes
- Test the recommendations: Post at suggested times for 2 weeks
Get Started
- Get your SociaVault API Key
- Run the analyzer on your own account
- Follow the personalized schedule
Stop guessing. Start posting when your audience is actually watching.
The algorithm doesn't decide. Your timing does.
Top comments (0)