DEV Community

Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a Best Time to Post Analyzer That Actually Works

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:

  1. Analyzes engagement patterns from your top-performing content
  2. Identifies YOUR optimal posting windows (not generic advice)
  3. 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
Enter fullscreen mode Exit fullscreen mode

Create .env:

SOCIAVAULT_API_KEY=your_sociavault_key
OPENAI_API_KEY=your_openai_key
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Run with:

node index.js charlidamelio tiktok
node index.js garyvee instagram
node index.js elonmusk twitter
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

  1. More data = better results: Analyze accounts with 50+ posts
  2. Account for time zones: Consider your audience's location
  3. Reanalyze monthly: Audience behavior changes
  4. Test the recommendations: Post at suggested times for 2 weeks

Get Started

  1. Get your SociaVault API Key
  2. Run the analyzer on your own account
  3. 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)