Skip to main content

Command Palette

Search for a command to run...

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

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

When 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
└──────────────────────┘

Layer 1: Window Manager

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

const windowConfig = {
  startHour: 8,
  endHour: 20,
  timezone: 'America/New_York'
};

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;
}

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;
  return baseInterval;
}

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);
}

For a 7-minute base interval, actual delays range from 2.8 to 15.4 minutes. The average is still ~7 minutes, so the daily cap is still roughly met.

The distribution of delays looks like natural human activity: some quick bursts, some long gaps.

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);
      await sleep(sleepUntil - Date.now());
      continue;
    }

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

    const module = selectNextModule(modules);
    if (!module) {
      await sleep(60_000);
      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);
  }
}

The loop is intentionally simple: check window → check cap → select module → execute → wait.

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 => m.isActive && !m.isModuleCapReached() && m.hasWorkToDo());
  if (ready.length === 0) return null;

  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];
}

Operators can weight modules: if Reply Search is more important than Welcome DM, set its weight higher.

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.

200 slots × 100 actions/day = 20,000 actions/day. Each action takes ~2 seconds of active execution. 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();
}

UTC midnight is the universal reset point. It's not 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.

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

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.


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