DEV Community

Cover image for Scheduling 10,000 Actions Across Time Zones: Work-Time Windows in Node.js
HelperX
HelperX

Posted on

Scheduling 10,000 Actions Across Time Zones: Work-Time Windows in Node.js

hen you automate actions on behalf of real people, the automation needs to look like real people. That means no activity at 3 AM. No perfect 60-second intervals. No machine-like consistency.

Here's how we built a work-time scheduling system that manages 10,000+ daily actions across accounts in different time zones — and makes each one look human.

The problem

Every account in HelperX has a work-time window: the hours during which automation is allowed to run. Outside this window, no actions happen.

Simple enough — until you consider:

  1. Accounts operate in different time zones
  2. Each account has a different daily cap (30-300+ actions)
  3. Actions need to be spread across the window, not clustered
  4. Delays between actions must be randomized
  5. The system needs to handle 200+ accounts simultaneously
  6. Cap resets happen at UTC midnight, regardless of local time

Architecture

The scheduler has three layers:

┌──────────────────────┐
│   Window Manager     │  ← Is this slot in its work window right now?
├──────────────────────┤
│   Action Distributor │  ← When should the next action happen?
├──────────────────────┤
│   Delay Randomizer   │  ← Add human-like jitter
└──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Layer 1: Window Manager

Each slot stores its work-time window as start/end hours:

const windowConfig = {
  startHour: 8,    // 8:00 AM
  endHour: 20,     // 8:00 PM
  timezone: 'America/New_York'
};
Enter fullscreen mode Exit fullscreen mode

The check is straightforward:

function isInWorkWindow(config) {
  const now = new Date();
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: config.timezone,
    hour: 'numeric',
    hour12: false
  });

  const localHour = parseInt(formatter.format(now));
  return localHour >= config.startHour && localHour < config.endHour;
}
Enter fullscreen mode Exit fullscreen mode

We use Intl.DateTimeFormat instead of a timezone library. It's built into Node.js, handles DST automatically, and doesn't add dependencies.

Layer 2: Action Distributor

Given a daily cap and a work-time window, how do you spread actions evenly?

function calculateInterval(dailyCap, startHour, endHour) {
  const windowHours = endHour - startHour;
  const windowMinutes = windowHours * 60;
  const baseInterval = windowMinutes / dailyCap; // minutes between actions

  return baseInterval;
}
Enter fullscreen mode Exit fullscreen mode

For 100 actions in a 12-hour window: 720 / 100 = 7.2 minutes between actions.

But uniform intervals are a detection signal. Real humans don't operate on 7.2-minute cycles.

Layer 3: Delay Randomizer

We apply jitter to every interval:

function getRandomizedDelay(baseIntervalMs) {
  const minMultiplier = 0.4;
  const maxMultiplier = 2.2;

  const multiplier = minMultiplier +
    Math.random() * (maxMultiplier - minMultiplier);

  return Math.round(baseIntervalMs * multiplier);
}
Enter fullscreen mode Exit fullscreen mode

For a 7-minute base interval, actual delays range from 2.8 to 15.4 minutes. The average is still ~7 minutes (the multiplier range is centered above 1.0 to account for slow periods), so the daily cap is still roughly met.

The distribution of delays looks like natural human activity: some quick bursts (checking X during a break, rapid replies), some long gaps (meetings, lunch, focused work).

The scheduling loop

Each slot runs an independent scheduling loop:

async function runSlotScheduler(slotId) {
  const config = getSlotConfig(slotId);
  const modules = getActiveModules(slotId);

  while (true) {
    if (!isInWorkWindow(config.workTime)) {
      const sleepUntil = getNextWindowStart(config.workTime);
      const sleepMs = sleepUntil - Date.now();
      await sleep(sleepMs);
      continue;
    }

    if (isDailyCapReached(slotId)) {
      const sleepUntil = getNextCapReset(); // midnight UTC
      const sleepMs = sleepUntil - Date.now();
      await sleep(sleepMs);
      continue;
    }

    const module = selectNextModule(modules);
    if (!module) {
      await sleep(60_000); // no modules ready, check again in 1 min
      continue;
    }

    await executeModuleAction(slotId, module);

    const baseInterval = calculateInterval(
      config.dailyCap,
      config.workTime.startHour,
      config.workTime.endHour
    );
    const delay = getRandomizedDelay(baseInterval * 60_000);
    await sleep(delay);
  }
}
Enter fullscreen mode Exit fullscreen mode

The loop is intentionally simple: check window → check cap → select module → execute → wait. No priority queues, no complex scheduling algorithms. The randomized delay handles distribution naturally.

Module round-robin

When a slot has multiple active modules (Reply Search, Regular Post, Welcome DM), the scheduler rotates between them:

function selectNextModule(modules) {
  const ready = modules.filter(m => {
    return m.isActive &&
           !m.isModuleCapReached() &&
           m.hasWorkToDo();
  });

  if (ready.length === 0) return null;

  // weighted selection based on module priority
  const weights = ready.map(m => m.config.weight || 1);
  const totalWeight = weights.reduce((a, b) => a + b, 0);
  let random = Math.random() * totalWeight;

  for (let i = 0; i < ready.length; i++) {
    random -= weights[i];
    if (random <= 0) return ready[i];
  }

  return ready[0];
}
Enter fullscreen mode Exit fullscreen mode

Operators can weight modules: if Reply Search is more important than Welcome DM, set its weight higher. The scheduler allocates proportionally more of the daily cap to higher-weighted modules.

Handling 200+ slots concurrently

Each slot scheduler is an async loop. Node.js handles hundreds of these concurrently through the event loop — no threads, no worker pools:

async function startAllSchedulers() {
  const slots = getAllActiveSlots();

  const schedulers = slots.map(slot =>
    runSlotScheduler(slot.id).catch(err => {
      logError(slot.id, 'scheduler_crash', err);
      // restart after backoff
      setTimeout(() => runSlotScheduler(slot.id), 30_000);
    })
  );

  await Promise.allSettled(schedulers);
}
Enter fullscreen mode Exit fullscreen mode

Each scheduler spends 99% of its time in await sleep(). When it's sleeping, it consumes zero CPU. Node.js's event loop wakes each one precisely when its delay expires.

200 slots × 100 actions/day = 20,000 actions/day. Each action takes ~2 seconds of active execution (network request + AI generation). Total active time: ~11 hours/day of cumulative work, spread across 200 concurrent async loops. Node.js handles this easily on a single core.

Cap reset at midnight UTC

All daily caps reset at UTC midnight, regardless of the slot's timezone:

function getNextCapReset() {
  const now = new Date();
  const tomorrow = new Date(now);
  tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
  tomorrow.setUTCHours(0, 0, 0, 0);
  return tomorrow.getTime();
}

function isDailyCapReached(slotId) {
  const db = getDb(slotId);
  const today = new Date().toISOString().slice(0, 10); // UTC date

  const count = db.prepare(`
    SELECT COUNT(*) as n FROM audit_log
    WHERE status = 'success'
    AND date(timestamp) = ?
  `).get(today);

  const cap = getSlotCap(slotId);
  return count.n >= cap;
}
Enter fullscreen mode Exit fullscreen mode

UTC midnight is the universal reset point. This means a slot in UTC+12 (New Zealand) resets at noon local time, while a slot in UTC-8 (PST) resets at 4 PM local time. The timing isn't ideal for every timezone, but it's consistent and predictable.

What we learned

1. Timezone handling is deceptively simple. Intl.DateTimeFormat handles DST, leap seconds, and edge cases. Don't bring in moment-timezone for something built into the runtime.

2. Randomized delays are more important than perfect scheduling. Uniform intervals are a detection signal. Random jitter between 0.4x and 2.2x the base interval produces realistic activity patterns.

3. Simple loops beat complex schedulers. We started with a priority queue system. It was harder to debug, harder to reason about, and didn't produce better results. The while-loop approach is dumb and effective.

4. Cap enforcement must be at the database level. An in-memory counter can drift, lose state on restart, or be bypassed. Count the actual rows in the audit log.

5. UTC midnight reset is a compromise everyone can live with. Per-timezone resets would require 24 different reset schedules and timezone-aware cap queries. UTC midnight is simple, consistent, and documented.


HelperX schedules actions across time zones with work-time windows, randomized delays, and server-side caps. Free 30-day trial.

Top comments (0)