DEV Community

Cover image for Building a Call Campaign Simulator with TypeScript
Abanoub Kerols
Abanoub Kerols

Posted on

Building a Call Campaign Simulator with TypeScript

When building a system that manages automated phone campaigns, the problem is much more complicated than simply looping through a list of phone numbers and calling them.

A real campaign system needs to answer questions such as:

  • When is a customer allowed to be called?
  • How many calls can run simultaneously?
  • What happens when a call fails?
  • When should a failed call be retried?
  • How many minutes can we spend calling customers per day?
  • What happens when the campaign is paused?
  • How do we handle different timezones?
  • How can we test time-dependent behavior without waiting for real time?

This article explains how to design and implement a Call Campaign Simulator using TypeScript.

The goal is not only to implement the feature, but also to understand the software engineering concepts behind it.

1. What Is a Call Campaign Simulator?

A call campaign simulator receives a list of customers and attempts to call them according to a set of business rules.

For example:

Customers:
    555-0001
    555-0002
    555-0003
    555-0004

Working Hours:
    09:00 → 17:00

Maximum Concurrent Calls:
    3

Daily Call Limit:
    120 minutes

Maximum Retries:
    2
Enter fullscreen mode Exit fullscreen mode

The system needs to coordinate all of these constraints.

Conceptually:

                 ┌────────────────────┐
                 │   Customer Queue   │
                 └─────────┬──────────┘
                           │
                           ▼
                 ┌────────────────────┐
                 │ Campaign Scheduler │
                 └─────────┬──────────┘
                           │
             ┌─────────────┼─────────────┐
             │             │             │
             ▼             ▼             ▼
          Call #1       Call #2       Call #3
             │             │             │
             └─────────────┼─────────────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │ Call Result     │
                  └────────┬────────┘
                           │
                 ┌─────────┴─────────┐
                 │                   │
               Success              Failure
                 │                   │
                 ▼                   ▼
             Processed             Retry
                                     │
                                     ▼
                              Retry Scheduler
Enter fullscreen mode Exit fullscreen mode

The important part is that the system is essentially a scheduler + state machine + concurrency controller.


2. Core Requirements

Our simulator supports the following features:

Sequential Processing

Customers are processed in the order they appear in the list.

[
  "555-0001",
  "555-0002",
  "555-0003"
]
Enter fullscreen mode Exit fullscreen mode

The system should not randomly reorder customers.


Working Hours

Calls are only allowed during a configured time window.

Example:

09:00 → 17:00
Enter fullscreen mode Exit fullscreen mode

If the current time is:

08:30
Enter fullscreen mode Exit fullscreen mode

the campaign must wait.

If the current time is:

18:00
Enter fullscreen mode Exit fullscreen mode

the campaign must also wait until the next valid working period.


Concurrency Control

Suppose:

maxConcurrentCalls = 3;
Enter fullscreen mode Exit fullscreen mode

The system can have at most three active calls:

Call A → active
Call B → active
Call C → active

Call D → waiting
Call E → waiting
Enter fullscreen mode Exit fullscreen mode

This is a classic concurrency limiting problem.


Daily Call Limit

Suppose:

maxDailyMinutes = 120;
Enter fullscreen mode Exit fullscreen mode

The campaign cannot consume more than 120 minutes of call time during a calendar day.

For example:

Call 1 = 30 minutes
Call 2 = 40 minutes
Call 3 = 20 minutes
Call 4 = 30 minutes
Enter fullscreen mode Exit fullscreen mode

Total:

30 + 40 + 20 + 30 = 120 minutes
Enter fullscreen mode Exit fullscreen mode

The daily limit has been reached.


3. Why This Problem Is Interesting

At first glance, the implementation seems simple:

for (const customer of customers) {
    await call(customer);
}
Enter fullscreen mode Exit fullscreen mode

But this ignores almost every important requirement.

We need to coordinate:

Time
   +
Concurrency
   +
Retries
   +
Daily Limits
   +
Pause / Resume
   +
Timezone
   +
State Management
Enter fullscreen mode Exit fullscreen mode

This makes the problem a good example of asynchronous system design.


4. State Machine

One of the most important concepts in the project is the campaign state.

The campaign can have states such as:

type CampaignState =
  | "idle"
  | "running"
  | "paused"
  | "completed";
Enter fullscreen mode Exit fullscreen mode

The state machine looks like:

             start()
                │
                ▼
             ┌───────┐
             │ idle  │
             └───┬───┘
                 │
                 ▼
             ┌─────────┐
       ┌────▶│ running │──────┐
       │     └────┬────┘      │
       │          │           │
    resume()   pause()     completed
       │          │           │
       │          ▼           ▼
       └──────┌────────┐   ┌───────────┐
              │ paused │   │ completed │
              └────────┘   └───────────┘
Enter fullscreen mode Exit fullscreen mode

This is a Finite State Machine (FSM).

The important idea is that not every operation should be allowed from every state.

For example:

idle → start()       valid

running → pause()    valid

paused → resume()    valid

completed → resume() invalid
Enter fullscreen mode Exit fullscreen mode

This prevents invalid transitions.


5. Data Model

The configuration can be represented using an interface.

export interface CampaignConfig {
  customerList: string[];

  startTime: string;
  endTime: string;

  maxConcurrentCalls: number;

  maxDailyMinutes: number;

  maxRetries?: number;

  retryDelayMs?: number;

  timezone?: string;
}
Enter fullscreen mode Exit fullscreen mode

The configuration describes the rules of the campaign.


6. Call Handler

The campaign should not know how the actual call is performed.

Instead, we inject a function.

export type CallHandler = (
  phoneNumber: string
) => Promise<{
  answered: boolean;
  durationMs: number;
}>;
Enter fullscreen mode Exit fullscreen mode

This is an important software design principle:

Separate business logic from infrastructure logic.

The Campaign class manages:

Scheduling
Concurrency
Retries
Limits
State
Enter fullscreen mode Exit fullscreen mode

The CallHandler manages:

Actual call operation
Enter fullscreen mode Exit fullscreen mode

This is similar to the Dependency Inversion Principle.


7. Why Dependency Injection Is Useful Here

Imagine we directly write:

class Campaign {
  async makeCall(phoneNumber: string) {
    // real telephony API
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing becomes difficult because every test would depend on the external telephony system.

Instead:

class Campaign {
  constructor(
    private callHandler: CallHandler
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now tests can inject:

const fakeCallHandler: CallHandler = async () => ({
  answered: true,
  durationMs: 5000
});
Enter fullscreen mode Exit fullscreen mode

This gives us deterministic tests.


8. The Clock Abstraction

Time is another dependency.

A naive implementation might use:

Date.now();
setTimeout(...);
clearTimeout(...);
Enter fullscreen mode Exit fullscreen mode

everywhere.

That makes testing time-dependent behavior difficult.

Instead, we define:

export interface IClock {
  now(): number;

  setTimeout(
    callback: () => void,
    delayMs: number
  ): number;

  clearTimeout(id: number): void;
}
Enter fullscreen mode Exit fullscreen mode

The campaign now depends on an abstraction instead of directly depending on real system time.


9. Injected Clock

Production code can use the real clock:

const clock: IClock = {
  now: () => Date.now(),

  setTimeout: (callback, delayMs) =>
    setTimeout(callback, delayMs) as unknown as number,

  clearTimeout: (id) =>
    clearTimeout(id)
};
Enter fullscreen mode Exit fullscreen mode

But tests can provide a fake clock.

For example:

class FakeClock implements IClock {
  private currentTime = 0;

  now(): number {
    return this.currentTime;
  }

  setTimeout(callback: () => void, delayMs: number): number {
    return 1;
  }

  clearTimeout(id: number): void {}
}
Enter fullscreen mode Exit fullscreen mode

Now tests don't have to wait for real time.


10. Working Hours

Suppose:

startTime = "09:00";
endTime = "17:00";
Enter fullscreen mode Exit fullscreen mode

We need to determine whether the current time is inside the allowed interval.

Conceptually:

function isWithinWorkingHours(
  currentTime: Date
): boolean {
  // convert current time to configured timezone

  // extract hours and minutes

  // compare against startTime and endTime
}
Enter fullscreen mode Exit fullscreen mode

For example:

08:59 → false
09:00 → true
12:30 → true
16:59 → true
17:00 → false
Enter fullscreen mode Exit fullscreen mode

Boundary conditions are important.


11. Waiting Until Working Hours

Suppose the campaign starts at:

07:30
Enter fullscreen mode Exit fullscreen mode

and working hours are:

09:00 → 17:00
Enter fullscreen mode Exit fullscreen mode

The campaign should not immediately call a customer.

Instead:

07:30
  │
  │ wait
  ▼
09:00
  │
  ▼
Start calling
Enter fullscreen mode Exit fullscreen mode

The scheduler needs to calculate:

delay = nextWorkingStart - currentTime;
Enter fullscreen mode Exit fullscreen mode

Then:

clock.setTimeout(
  () => processNext(),
  delay
);
Enter fullscreen mode Exit fullscreen mode

12. Concurrency Control

Concurrency is one of the most important parts of the system.

Suppose:

maxConcurrentCalls = 2;
Enter fullscreen mode Exit fullscreen mode

and we have:

Customer A
Customer B
Customer C
Customer D
Enter fullscreen mode Exit fullscreen mode

The system should produce:

Time →

A ────────────────
B ──────────
C       ───────────────
D             ──────────
Enter fullscreen mode Exit fullscreen mode

But never:

A ───────
B ───────
C ───────   ❌
Enter fullscreen mode Exit fullscreen mode

because that would mean three simultaneous calls.


13. Concurrency Counter

A simple mechanism is:

private activeCalls = 0;
Enter fullscreen mode Exit fullscreen mode

Before starting a call:

if (
  this.activeCalls >=
  this.config.maxConcurrentCalls
) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

When the call starts:

this.activeCalls++;
Enter fullscreen mode Exit fullscreen mode

When it finishes:

this.activeCalls--;
Enter fullscreen mode Exit fullscreen mode

This gives us a basic semaphore-like mechanism.


14. Semaphore Concept

A semaphore controls access to a limited resource.

If we have:

3 permits
Enter fullscreen mode Exit fullscreen mode

then only three operations can execute simultaneously.

Initially:

Available permits = 3
Enter fullscreen mode Exit fullscreen mode

Call A:

Available = 2
Enter fullscreen mode Exit fullscreen mode

Call B:

Available = 1
Enter fullscreen mode Exit fullscreen mode

Call C:

Available = 0
Enter fullscreen mode Exit fullscreen mode

Call D:

WAIT
Enter fullscreen mode Exit fullscreen mode

When A finishes:

Available = 1
Enter fullscreen mode Exit fullscreen mode

D can now start.

This is exactly the type of resource management our campaign needs.


15. Daily Cap

Suppose:

maxDailyMinutes = 120;
Enter fullscreen mode Exit fullscreen mode

We maintain:

private dailyMinutesUsed = 0;
Enter fullscreen mode Exit fullscreen mode

When a call finishes:

const minutes =
  durationMs / 60000;

this.dailyMinutesUsed += minutes;
Enter fullscreen mode Exit fullscreen mode

Before starting another call, we need to verify that the daily limit hasn't been reached.


16. Important Question: When Do We Count Call Minutes?

This is a business-rule decision.

Consider:

Remaining capacity = 5 minutes
Enter fullscreen mode Exit fullscreen mode

and a call lasts:

10 minutes
Enter fullscreen mode Exit fullscreen mode

There are several possible policies.

Policy A — Allow the call

Start the call and count the actual duration afterward.

Policy B — Reject the call

Don't start a call that could exceed the daily limit.

Policy C — Allow but cap accounting

Count only the remaining available minutes.

For a simulator, the cleanest approach is usually to clearly define the behavior in the requirements and implement it consistently.

This illustrates an important engineering principle:

Business rules should be explicit rather than hidden inside implementation details.


17. Daily Reset

The daily cap is not permanent.

For example:

Monday:
120 minutes used

Tuesday:
0 minutes used
Enter fullscreen mode Exit fullscreen mode

So the system needs to know when a new calendar day begins.

This becomes more complicated when timezones are introduced.


18. Why Timezones Matter

Imagine:

timezone = "America/New_York";
Enter fullscreen mode Exit fullscreen mode

The application server might actually be running in:

UTC
Enter fullscreen mode Exit fullscreen mode

The campaign's business rules should still use:

New York time
Enter fullscreen mode Exit fullscreen mode

For example:

Server:
14:00 UTC

New York:
10:00
Enter fullscreen mode Exit fullscreen mode

If working hours are:

09:00 → 17:00
Enter fullscreen mode Exit fullscreen mode

the call is allowed.

Therefore, we should never blindly use server-local time.


19. IANA Timezones

The project uses IANA timezone identifiers.

Examples:

America/New_York
Europe/London
Asia/Tokyo
Africa/Cairo
Enter fullscreen mode Exit fullscreen mode

These identifiers allow the application to correctly interpret local time.

A library such as Luxon makes this easier.

Example:

import { DateTime } from "luxon";

const now = DateTime.now()
  .setZone("America/New_York");

console.log(now.toISO());
Enter fullscreen mode Exit fullscreen mode

20. DST — Daylight Saving Time

Timezone handling becomes even more interesting with DST.

For example, some countries change their clocks during the year.

A hardcoded offset such as:

UTC-5
Enter fullscreen mode Exit fullscreen mode

is therefore dangerous.

The correct approach is to use:

America/New_York
Enter fullscreen mode Exit fullscreen mode

instead of manually calculating:

UTC - 5
Enter fullscreen mode Exit fullscreen mode

The timezone database can determine the correct offset.

This is one reason timezone-aware libraries are useful.


21. Retry Logic

Calls can fail.

For example:

Customer A → success
Customer B → failed
Customer C → success
Enter fullscreen mode Exit fullscreen mode

The system shouldn't necessarily permanently fail Customer B.

Instead:

Attempt 1
   │
   ▼
Failed
   │
   ▼
Wait
   │
   ▼
Attempt 2
Enter fullscreen mode Exit fullscreen mode

If it fails again:

Attempt 2
   │
   ▼
Failed
   │
   ▼
Attempt 3
Enter fullscreen mode Exit fullscreen mode

After the maximum retry count:

Permanently Failed
Enter fullscreen mode Exit fullscreen mode

22. Retry Configuration

The configuration contains:

maxRetries: 2,
retryDelayMs: 3600000
Enter fullscreen mode Exit fullscreen mode

This means the system can retry a failed call according to the configured retry policy.

The delay:

3600000 ms
Enter fullscreen mode Exit fullscreen mode

equals:

1 hour
Enter fullscreen mode Exit fullscreen mode

because:

1000 ms = 1 second

60 seconds = 1 minute

60 minutes = 1 hour
Enter fullscreen mode Exit fullscreen mode

Therefore:

1000 × 60 × 60 = 3,600,000 ms
Enter fullscreen mode Exit fullscreen mode

23. Retry Queue

Instead of immediately retrying:

await retry();
Enter fullscreen mode Exit fullscreen mode

we can schedule it:

clock.setTimeout(
  () => retryCustomer(customer),
  retryDelayMs
);
Enter fullscreen mode Exit fullscreen mode

The system can maintain:

private pendingRetries = 0;
Enter fullscreen mode Exit fullscreen mode

When a retry is scheduled:

this.pendingRetries++;
Enter fullscreen mode Exit fullscreen mode

When the retry starts:

this.pendingRetries--;
Enter fullscreen mode Exit fullscreen mode

24. Exponential Backoff

A more advanced retry strategy is exponential backoff.

Instead of:

1 hour
1 hour
1 hour
Enter fullscreen mode Exit fullscreen mode

we could use:

1 minute
2 minutes
4 minutes
8 minutes
Enter fullscreen mode Exit fullscreen mode

The formula is:

delay =
  baseDelay * Math.pow(2, attempt);
Enter fullscreen mode Exit fullscreen mode

For example:

attempt 0 → 1 minute
attempt 1 → 2 minutes
attempt 2 → 4 minutes
attempt 3 → 8 minutes
Enter fullscreen mode Exit fullscreen mode

This is commonly used in distributed systems.


25. Pause and Resume

The campaign supports:

campaign.pause();
Enter fullscreen mode Exit fullscreen mode

and:

campaign.resume();
Enter fullscreen mode Exit fullscreen mode

The important business rule is:

Pausing the campaign does not necessarily cancel active calls.

For example:

Call A ────────────────► finishes
Call B ───────────► finishes

Campaign
         pause()
           │
           ▼
       PAUSED
Enter fullscreen mode Exit fullscreen mode

Existing calls can finish while new calls should not be started.


26. Pause vs Cancellation

These concepts are different.

Pause

Stops starting new work.

Cancellation

Attempts to terminate existing work.

For example:

Pause:

Existing calls → continue
New calls      → blocked
Enter fullscreen mode Exit fullscreen mode

Whereas cancellation could mean:

Existing calls → terminate if possible
New calls      → blocked
Enter fullscreen mode Exit fullscreen mode

This distinction is important when designing asynchronous systems.


27. Campaign Status

The system exposes:

interface CampaignStatus {
  state:
    | "idle"
    | "running"
    | "paused"
    | "completed";

  totalProcessed: number;

  totalFailed: number;

  activeCalls: number;

  pendingRetries: number;

  dailyMinutesUsed: number;
}
Enter fullscreen mode Exit fullscreen mode

This gives the outside world a snapshot of the campaign.

Example:

const status = campaign.getStatus();

console.log(status);
Enter fullscreen mode Exit fullscreen mode

Possible output:

{
  state: "running",
  totalProcessed: 25,
  totalFailed: 2,
  activeCalls: 3,
  pendingRetries: 1,
  dailyMinutesUsed: 87.5
}
Enter fullscreen mode Exit fullscreen mode

28. The Campaign Class

The central class can look conceptually like this:

export class Campaign {
  constructor(
    private config: CampaignConfig,
    private callHandler: CallHandler,
    private clock: IClock
  ) {}

  start(): void {
    // start campaign
  }

  pause(): void {
    // pause campaign
  }

  resume(): void {
    // resume campaign
  }

  getStatus(): CampaignStatus {
    // return campaign state
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps the public API small.


29. Internal State

The campaign needs internal state such as:

private state: CampaignState = "idle";

private currentIndex = 0;

private activeCalls = 0;

private totalProcessed = 0;

private totalFailed = 0;

private pendingRetries = 0;

private dailyMinutesUsed = 0;
Enter fullscreen mode Exit fullscreen mode

These variables represent the current state of the campaign.


30. Processing the Customer Queue

A basic processing function might look like:

private processNext(): void {
  if (this.state !== "running") {
    return;
  }

  if (
    this.activeCalls >=
    this.config.maxConcurrentCalls
  ) {
    return;
  }

  if (
    this.currentIndex >=
    this.config.customerList.length
  ) {
    this.checkCompletion();
    return;
  }

  const customer =
    this.config.customerList[
      this.currentIndex
    ];

  this.currentIndex++;

  this.executeCall(customer);
}
Enter fullscreen mode Exit fullscreen mode

The important idea is that every condition acts as a gate.


31. The Scheduling Pipeline

Before starting a call, we conceptually check:

Is campaign running?
        │
        ▼
Are we inside working hours?
        │
        ▼
Is concurrency available?
        │
        ▼
Is daily capacity available?
        │
        ▼
Start call
Enter fullscreen mode Exit fullscreen mode

This can be represented as:

                 ┌───────────────┐
                 │ Campaign      │
                 │ running?      │
                 └───────┬───────┘
                         │ yes
                         ▼
                 ┌───────────────┐
                 │ Working       │
                 │ hours?        │
                 └───────┬───────┘
                         │ yes
                         ▼
                 ┌───────────────┐
                 │ Concurrency   │
                 │ available?    │
                 └───────┬───────┘
                         │ yes
                         ▼
                 ┌───────────────┐
                 │ Daily cap     │
                 │ available?    │
                 └───────┬───────┘
                         │ yes
                         ▼
                    START CALL
Enter fullscreen mode Exit fullscreen mode

This is effectively a scheduling decision tree.


32. Executing a Call

The call itself can be handled asynchronously:

private async executeCall(
  phoneNumber: string
): Promise<void> {
  this.activeCalls++;

  try {
    const result =
      await this.callHandler(phoneNumber);

    this.handleCallResult(
      phoneNumber,
      result
    );
  } finally {
    this.activeCalls--;
    this.processAvailableCalls();
  }
}
Enter fullscreen mode Exit fullscreen mode

The finally block is particularly important.

Whether the call succeeds or fails:

this.activeCalls--;
Enter fullscreen mode Exit fullscreen mode

must happen.

Otherwise the campaign could become permanently stuck.


33. Why finally Matters

Bad implementation:

try {
  await call();
  activeCalls--;
} catch {
  // activeCalls never decremented
}
Enter fullscreen mode Exit fullscreen mode

If the call throws an exception:

activeCalls = 1

call throws

activeCalls stays 1
Enter fullscreen mode Exit fullscreen mode

Eventually the system may believe that the concurrency limit has been reached forever.

Better:

try {
  await call();
} finally {
  activeCalls--;
}
Enter fullscreen mode Exit fullscreen mode

This is a classic resource-cleanup pattern.


34. Handling Success

Suppose the call result is:

{
  answered: true,
  durationMs: 120000
}
Enter fullscreen mode Exit fullscreen mode

We can calculate:

const minutes =
  durationMs / 60000;
Enter fullscreen mode Exit fullscreen mode

Then:

this.dailyMinutesUsed += minutes;
this.totalProcessed++;
Enter fullscreen mode Exit fullscreen mode

The customer is now considered successfully processed.


35. Handling Failure

Suppose:

{
  answered: false,
  durationMs: 5000
}
Enter fullscreen mode Exit fullscreen mode

The business rule can decide that the call should be retried.

Conceptually:

if (attempt < maxRetries) {
  scheduleRetry();
} else {
  totalFailed++;
}
Enter fullscreen mode Exit fullscreen mode

This creates two possible paths:

Failure
   │
   ├── retries remaining → Retry
   │
   └── no retries → Permanent failure
Enter fullscreen mode Exit fullscreen mode

36. Attempt Tracking

Each customer needs retry information.

For example:

private retryAttempts =
  new Map<string, number>();
Enter fullscreen mode Exit fullscreen mode

When a customer fails:

const attempts =
  this.retryAttempts.get(phoneNumber) ?? 0;

this.retryAttempts.set(
  phoneNumber,
  attempts + 1
);
Enter fullscreen mode Exit fullscreen mode

This allows the system to know how many times a particular customer has already been attempted.


37. Important Design Question: Customer Identity

Using the phone number as a key is convenient:

Map<string, number>
Enter fullscreen mode Exit fullscreen mode

But in a real system, phone numbers might not be unique or stable.

A production system would usually have:

customerId
phoneNumber
Enter fullscreen mode Exit fullscreen mode

For example:

interface Customer {
  id: string;
  phoneNumber: string;
}
Enter fullscreen mode Exit fullscreen mode

Then retry state can use:

Map<CustomerId, RetryState>
Enter fullscreen mode Exit fullscreen mode

This is more robust.


38. Sequential Processing vs Concurrent Execution

The requirement says:

Process the customer list sequentially.

This does not necessarily mean:

await customer1;
await customer2;
await customer3;
Enter fullscreen mode Exit fullscreen mode

because that would make concurrency impossible.

Instead, sequential processing usually means:

Customers are selected from the queue in order, while multiple selected calls may execute concurrently up to the configured limit.

For example:

Queue:

A
B
C
D
E

maxConcurrent = 2

A ──────────
B ─────────────

C       ─────────
D       ────────

E             ─────────
Enter fullscreen mode Exit fullscreen mode

The selection order remains:

A → B → C → D → E
Enter fullscreen mode Exit fullscreen mode

while execution overlaps.

This distinction is very important.


39. Completion Detection

The campaign should only become:

"completed"
Enter fullscreen mode Exit fullscreen mode

when there is no more work.

That usually means:

Customer queue empty
+
Active calls = 0
+
Pending retries = 0
Enter fullscreen mode Exit fullscreen mode

Therefore:

if (
  currentIndex >= customerList.length &&
  activeCalls === 0 &&
  pendingRetries === 0
) {
  state = "completed";
}
Enter fullscreen mode Exit fullscreen mode

This avoids declaring completion too early.


40. Example Scenario

Consider:

const config: CampaignConfig = {
  customerList: [
    "555-0001",
    "555-0002",
    "555-0003",
    "555-0004"
  ],

  startTime: "09:00",

  endTime: "17:00",

  maxConcurrentCalls: 2,

  maxDailyMinutes: 60,

  maxRetries: 2,

  retryDelayMs: 60000,

  timezone: "America/New_York"
};
Enter fullscreen mode Exit fullscreen mode

Suppose:

09:00
Enter fullscreen mode Exit fullscreen mode

The campaign starts.


41. Initial Execution

Concurrency limit:

2
Enter fullscreen mode Exit fullscreen mode

So:

Call 555-0001
Call 555-0002
Enter fullscreen mode Exit fullscreen mode

are started.

The remaining customers wait:

555-0003
555-0004
Enter fullscreen mode Exit fullscreen mode

42. First Call Finishes

Suppose:

555-0001
answered = true
duration = 10 minutes
Enter fullscreen mode Exit fullscreen mode

Now:

dailyMinutesUsed = 10
activeCalls = 1
Enter fullscreen mode Exit fullscreen mode

The scheduler can start:

555-0003
Enter fullscreen mode Exit fullscreen mode

Now:

555-0002 → active
555-0003 → active
Enter fullscreen mode Exit fullscreen mode

43. Failed Call

Suppose:

555-0002
answered = false
Enter fullscreen mode Exit fullscreen mode

The system schedules a retry:

retry in 60 seconds
Enter fullscreen mode Exit fullscreen mode

Status:

pendingRetries = 1
Enter fullscreen mode Exit fullscreen mode

The campaign can continue processing other customers if capacity allows.


44. Retry

After the delay:

555-0002 → retry
Enter fullscreen mode Exit fullscreen mode

If successful:

totalProcessed++
Enter fullscreen mode Exit fullscreen mode

If it fails again:

attempt = 2
Enter fullscreen mode Exit fullscreen mode

and the system checks whether another retry is allowed.


45. Daily Cap Reached

Suppose:

dailyMinutesUsed = 58
Enter fullscreen mode Exit fullscreen mode

and a call finishes with:

duration = 5 minutes
Enter fullscreen mode Exit fullscreen mode

Now:

dailyMinutesUsed = 63
Enter fullscreen mode Exit fullscreen mode

The campaign has exceeded the configured limit if the system permits the call to finish.

At this point, the campaign should stop scheduling additional calls until the next applicable calendar day, according to the chosen business rule.


46. Why Luxon?

JavaScript's native Date API can become difficult to work with when implementing complex timezone behavior.

Luxon provides a cleaner API.

For example:

import { DateTime } from "luxon";

const now = DateTime.now()
  .setZone("Europe/London");

console.log(now.hour);
console.log(now.minute);
Enter fullscreen mode Exit fullscreen mode

We can also calculate the start of the day:

const startOfDay =
  now.startOf("day");
Enter fullscreen mode Exit fullscreen mode

And the next day:

const nextDay =
  now.plus({ days: 1 })
     .startOf("day");
Enter fullscreen mode Exit fullscreen mode

This is useful for daily cap calculations.


47. Calendar Day vs 24 Hours

An important distinction:

Daily cap
Enter fullscreen mode Exit fullscreen mode

usually means a calendar day, not:

rolling 24-hour window
Enter fullscreen mode Exit fullscreen mode

For example:

Monday 23:50 → Tuesday 00:10
Enter fullscreen mode Exit fullscreen mode

A calendar-day implementation treats these as different days.

A rolling-window implementation would treat them as part of the same 24-hour period.

The requirements explicitly describe:

maximum total call minutes per calendar day

Therefore the reset should happen at local midnight in the configured timezone.


48. Testing Time-Dependent Systems

One of the hardest things to test is code like:

setTimeout(() => {
  // retry after one hour
}, 3600000);
Enter fullscreen mode Exit fullscreen mode

A normal test would have to wait one hour.

Obviously, that's unacceptable.

This is why dependency injection is so useful.

Instead of directly using:

Date.now()
Enter fullscreen mode Exit fullscreen mode

we use:

clock.now()
Enter fullscreen mode Exit fullscreen mode

Instead of:

setTimeout()
Enter fullscreen mode Exit fullscreen mode

we use:

clock.setTimeout()
Enter fullscreen mode Exit fullscreen mode

Now tests can control the clock.


49. Deterministic Testing

Suppose we want to test:

Retry after 1 hour
Enter fullscreen mode Exit fullscreen mode

With a fake clock:

clock.advanceBy(60 * 60 * 1000);
Enter fullscreen mode Exit fullscreen mode

We can simulate one hour instantly.

The test becomes:

Start
  ↓
Failure
  ↓
Schedule retry
  ↓
Advance clock 1 hour
  ↓
Retry executes
Enter fullscreen mode Exit fullscreen mode

No real waiting is necessary.

This is an example of deterministic testing.


50. Testing Concurrency

We should test that:

activeCalls <= maxConcurrentCalls
Enter fullscreen mode Exit fullscreen mode

at all times.

For:

maxConcurrentCalls = 3
Enter fullscreen mode Exit fullscreen mode

we should never observe:

activeCalls = 4
Enter fullscreen mode Exit fullscreen mode

A test can use a controlled call handler:

let active = 0;
let maximumObserved = 0;

const handler: CallHandler =
  async () => {
    active++;

    maximumObserved =
      Math.max(
        maximumObserved,
        active
      );

    // simulated work

    active--;

    return {
      answered: true,
      durationMs: 1000
    };
  };
Enter fullscreen mode Exit fullscreen mode

Then assert:

expect(maximumObserved)
  .toBeLessThanOrEqual(3);
Enter fullscreen mode Exit fullscreen mode

51. Testing Working Hours

Important test cases include:

08:59 → should not call
09:00 → should call
12:00 → should call
16:59 → should call
17:00 → should stop
Enter fullscreen mode Exit fullscreen mode

Boundary conditions are often where scheduling bugs occur.


52. Testing Pause and Resume

Example:

Campaign starts
      ↓
Calls start
      ↓
pause()
      ↓
No new calls
      ↓
Active calls finish
      ↓
resume()
      ↓
New calls continue
Enter fullscreen mode Exit fullscreen mode

The test should verify that pause does not accidentally create duplicate processing.


53. Testing Retries

A good retry test might simulate:

Attempt 1 → fail
Attempt 2 → fail
Attempt 3 → success
Enter fullscreen mode Exit fullscreen mode

and verify:

totalProcessed === 1
totalFailed === 0
Enter fullscreen mode Exit fullscreen mode

Another test:

Attempt 1 → fail
Attempt 2 → fail
Attempt 3 → fail
Enter fullscreen mode Exit fullscreen mode

should produce:

totalProcessed === 0
totalFailed === 1
Enter fullscreen mode Exit fullscreen mode

assuming the configured retry policy allows those attempts.


54. Testing Timezones

Timezone tests are particularly important.

For example:

Timezone:
America/New_York

Working hours:
09:00 → 17:00
Enter fullscreen mode Exit fullscreen mode

The test should verify that UTC time is correctly converted into New York local time.

DST transition dates should also be tested.

This is one of the strongest reasons to abstract time and use a timezone-aware library.


55. A Complete Usage Example

The public API can remain simple:

import { Campaign } from "./solution";

import {
  IClock,
  CallHandler,
  CampaignConfig
} from "./interfaces";

const clock: IClock = {
  now: () => Date.now(),

  setTimeout: (callback, delayMs) =>
    setTimeout(
      callback,
      delayMs
    ) as unknown as number,

  clearTimeout: (id) =>
    clearTimeout(id)
};

const callHandler: CallHandler =
  async (phoneNumber) => {

    console.log(
      `Calling ${phoneNumber}`
    );

    return {
      answered:
        Math.random() > 0.2,

      durationMs:
        Math.random() * 600000
    };
  };

const config: CampaignConfig = {
  customerList: [
    "555-0001",
    "555-0002",
    "555-0003",
    "555-0004"
  ],

  startTime: "09:00",

  endTime: "17:00",

  maxConcurrentCalls: 3,

  maxDailyMinutes: 120,

  maxRetries: 2,

  retryDelayMs: 3600000,

  timezone: "America/New_York"
};

const campaign =
  new Campaign(
    config,
    callHandler,
    clock
  );

campaign.start();
Enter fullscreen mode Exit fullscreen mode

Then we can inspect the status:

setInterval(() => {
  const status =
    campaign.getStatus();

  console.log({
    state: status.state,

    processed:
      status.totalProcessed,

    failed:
      status.totalFailed,

    activeCalls:
      status.activeCalls,

    pendingRetries:
      status.pendingRetries,

    dailyMinutes:
      status.dailyMinutesUsed
  });
}, 1000);
Enter fullscreen mode Exit fullscreen mode

56. Architectural View

The project can be viewed as several logical components:

                    Campaign
                       │
       ┌───────────────┼────────────────┐
       │               │                │
       ▼               ▼                ▼
  Scheduler        Call Handler      State
       │               │                │
       ▼               ▼                ▼
 Working Hours      External API     Metrics
 Concurrency
 Daily Cap
 Retries
 Timezone
Enter fullscreen mode Exit fullscreen mode

The Campaign orchestrates the system.


57. Separation of Concerns

A good implementation should avoid putting everything into one giant function.

Instead, separate responsibilities.

For example:

isWithinWorkingHours()
Enter fullscreen mode Exit fullscreen mode

handles working hours.

getNextWorkingTime()
Enter fullscreen mode Exit fullscreen mode

handles scheduling.

executeCall()
Enter fullscreen mode Exit fullscreen mode

handles call execution.

scheduleRetry()
Enter fullscreen mode Exit fullscreen mode

handles retries.

checkDailyLimit()
Enter fullscreen mode Exit fullscreen mode

handles daily capacity.

checkCompletion()
Enter fullscreen mode Exit fullscreen mode

handles completion.

This makes the code easier to understand and test.


58. Common Mistakes

Mistake 1 — Using Promise.all()

A naive implementation:

await Promise.all(
  customers.map(call)
);
Enter fullscreen mode Exit fullscreen mode

can create hundreds or thousands of simultaneous calls.

That violates:

maxConcurrentCalls
Enter fullscreen mode Exit fullscreen mode

Mistake 2 — Using Recursive Calls Without Guards

Something like:

processNext();
Enter fullscreen mode Exit fullscreen mode

inside multiple asynchronous paths can accidentally create duplicate processing.

A scheduler should carefully control when the next task can start.


Mistake 3 — Forgetting finally

As discussed earlier:

activeCalls++;
Enter fullscreen mode Exit fullscreen mode

must eventually be paired with:

activeCalls--;
Enter fullscreen mode Exit fullscreen mode

even when an exception occurs.


Mistake 4 — Using Server Time for Business Rules

This is dangerous:

new Date();
Enter fullscreen mode Exit fullscreen mode

without considering the configured campaign timezone.

Business time should be calculated using:

configured timezone
Enter fullscreen mode Exit fullscreen mode

Mistake 5 — Treating Retry as a New Customer

A retry should not incorrectly increment:

totalProcessed
Enter fullscreen mode Exit fullscreen mode

until the customer actually succeeds.

Retry attempts and successfully processed customers are different metrics.


59. Metrics vs Events

Another useful distinction is between:

Attempts
Enter fullscreen mode Exit fullscreen mode

and:

Processed Customers
Enter fullscreen mode Exit fullscreen mode

Suppose:

Customer A:
Attempt 1 → failed
Attempt 2 → failed
Attempt 3 → success
Enter fullscreen mode Exit fullscreen mode

The system made:

3 attempts
Enter fullscreen mode Exit fullscreen mode

but successfully processed:

1 customer
Enter fullscreen mode Exit fullscreen mode

These should not be confused.

A production system might expose:

totalAttempts
totalProcessed
totalFailed
Enter fullscreen mode Exit fullscreen mode

instead of only two counters.


60. What Happens if the Process Crashes?

The current simulator is primarily an in-memory system.

That means state such as:

currentIndex
activeCalls
dailyMinutesUsed
retryAttempts
Enter fullscreen mode Exit fullscreen mode

can disappear if the Node.js process crashes.

A production campaign platform would need persistent state.

For example:

Campaign Service
       │
       ▼
   PostgreSQL
       │
       ├── campaigns
       ├── customers
       ├── call_attempts
       └── retry_jobs
Enter fullscreen mode Exit fullscreen mode

Or possibly:

Redis
Enter fullscreen mode Exit fullscreen mode

for short-lived scheduling state.


61. Production Architecture

A more advanced architecture could look like:

                    API
                     │
                     ▼
             Campaign Service
                     │
          ┌──────────┴──────────┐
          │                     │
          ▼                     ▼
      Database                Queue
                                │
                  ┌─────────────┼─────────────┐
                  │             │             │
                  ▼             ▼             ▼
               Worker        Worker        Worker
                  │             │             │
                  └─────────────┼─────────────┘
                                │
                                ▼
                         Telephony Provider
Enter fullscreen mode Exit fullscreen mode

The simulator's concurrency limit becomes a distributed worker problem.


62. Queue-Based Architecture

For a real system, instead of keeping all work inside one process, we could use a message queue.

For example:

Campaign
   │
   ▼
Queue
   │
   ├── Job 1
   ├── Job 2
   ├── Job 3
   └── Job 4
Enter fullscreen mode Exit fullscreen mode

Workers consume jobs.

Concurrency can then be controlled by:

Number of workers
+
Per-worker concurrency
Enter fullscreen mode Exit fullscreen mode

This architecture scales much better.


63. Idempotency

Distributed systems introduce another important problem:

What happens if the same call job is processed twice?

For example:

Worker A:
starts call

Network failure

Queue thinks job failed

Worker B:
starts same job
Enter fullscreen mode Exit fullscreen mode

Now the customer might receive two calls.

A production system therefore needs an idempotency strategy.

For example:

idempotencyKey =
  `${campaignId}:${customerId}:${attempt}`;
Enter fullscreen mode Exit fullscreen mode

Before starting the call, the system can check whether that attempt was already executed.


64. Race Conditions

Concurrency introduces race conditions.

Consider:

Daily limit remaining:
5 minutes
Enter fullscreen mode Exit fullscreen mode

Two workers check simultaneously:

Worker A → sees 5 minutes
Worker B → sees 5 minutes
Enter fullscreen mode Exit fullscreen mode

Both decide:

Allowed
Enter fullscreen mode Exit fullscreen mode

Then both consume:

5 minutes
Enter fullscreen mode Exit fullscreen mode

Now the system has exceeded its intended capacity.

This is a classic check-then-act race condition.

A production system would need synchronization, transactions, atomic operations, or another coordination mechanism.


65. Why the Simulator Is Valuable

Although this project is called a simulator, it demonstrates several real backend concepts:

Async Programming
Concurrency
Scheduling
State Machines
Dependency Injection
Retry Strategies
Resource Limits
Timezones
Testing
Race Conditions
Queue Processing
Fault Tolerance
Enter fullscreen mode Exit fullscreen mode

These are not merely theoretical concepts.

They appear in:

  • Job schedulers
  • Email systems
  • Payment processing
  • Notification services
  • Data pipelines
  • Background workers
  • Distributed systems
  • Call centers
  • IoT platforms

66. Complexity

Suppose there are:

N customers
Enter fullscreen mode Exit fullscreen mode

Each customer is processed a finite number of times based on retry configuration.

If:

R = maximum retries
Enter fullscreen mode Exit fullscreen mode

the maximum number of attempts is approximately:

N × (R + 1)
Enter fullscreen mode Exit fullscreen mode

For example:

100 customers
2 retries
Enter fullscreen mode Exit fullscreen mode

Maximum attempts:

100 × 3 = 300
Enter fullscreen mode Exit fullscreen mode

The scheduler therefore needs to handle potentially much more work than the number of customers.


67. Memory Complexity

If we maintain retry state for each customer:

Map<CustomerId, RetryState>
Enter fullscreen mode Exit fullscreen mode

the memory requirement is approximately:

O(N)
Enter fullscreen mode Exit fullscreen mode

where N is the number of customers.

Other queues and state structures may also contribute to memory usage.


68. Concurrency Complexity

The system limits active calls to:

C
Enter fullscreen mode Exit fullscreen mode

where:

C = maxConcurrentCalls
Enter fullscreen mode Exit fullscreen mode

Therefore the number of simultaneously active operations is bounded by:

O(C)
Enter fullscreen mode Exit fullscreen mode

rather than:

O(N)
Enter fullscreen mode Exit fullscreen mode

This is one of the primary purposes of the concurrency control mechanism.


69. Key Design Principles

This project demonstrates several important principles.

Single Responsibility

Each part of the system should have one main responsibility.

Dependency Inversion

Depend on:

IClock
CallHandler
Enter fullscreen mode Exit fullscreen mode

rather than concrete implementations.

Separation of Concerns

Scheduling logic should not be mixed with call-provider implementation.

Explicit State

Campaign state should be represented explicitly.

Deterministic Testing

Inject time and external dependencies.

Defensive Resource Management

Always release resources using patterns such as:

finally
Enter fullscreen mode Exit fullscreen mode

70. Complete Mental Model

The easiest way to understand the entire system is:

                CUSTOMER LIST
                      │
                      ▼
                QUEUE / INDEX
                      │
                      ▼
              ┌───────────────┐
              │   Scheduler   │
              └───────┬───────┘
                      │
          ┌───────────┼───────────┐
          │           │           │
          ▼           ▼           ▼
       Time        Capacity     State
          │           │           │
          └───────────┼───────────┘
                      │
                      ▼
                 CALL HANDLER
                      │
             ┌────────┴────────┐
             │                 │
          Success            Failure
             │                 │
             ▼                 ▼
        Update stats        Retry?
             │                 │
             │          ┌──────┴──────┐
             │          │             │
             │         Yes            No
             │          │             │
             │          ▼             ▼
             │       Schedule       Failed
             │        retry
             │          │
             └──────────┴─────────────┐
                                      │
                                      ▼
                              Check Completion
                                      │
                                      ▼
                                  COMPLETED
Enter fullscreen mode Exit fullscreen mode

71. Final Takeaways

Building a call campaign simulator is a great exercise because it looks simple but contains many real-world backend problems.

The main lessons are:

1. Concurrency must be controlled

Don't start unlimited asynchronous operations.

maxConcurrentCalls
Enter fullscreen mode Exit fullscreen mode

defines the resource boundary.

2. Time should be abstracted

Instead of tightly coupling the application to:

Date.now()
setTimeout()
Enter fullscreen mode Exit fullscreen mode

inject a clock.

3. Timezones are business logic

Working hours and daily limits should be evaluated in the campaign's configured timezone.

4. Retries need explicit policies

A retry system should define:

maximum attempts
delay
backoff strategy
permanent failure behavior
Enter fullscreen mode Exit fullscreen mode

5. State machines simplify lifecycle management

Explicit states such as:

idle
running
paused
completed
Enter fullscreen mode Exit fullscreen mode

make the campaign's behavior predictable.

6. Testing asynchronous systems requires control

Injected clocks and fake call handlers allow tests to run quickly and deterministically.

7. In-memory solutions are different from production systems

A simulator can keep state in memory.

A production system usually needs:

Database
Queue
Workers
Idempotency
Transactions
Distributed locking
Monitoring
Enter fullscreen mode Exit fullscreen mode

Conclusion

The Call Campaign Simulator is more than a TypeScript exercise.

It is a compact example of how backend systems coordinate:

Time
+
Concurrency
+
Retries
+
Resource Limits
+
State
+
External Dependencies
Enter fullscreen mode Exit fullscreen mode

The most important lesson is that asynchronous programming is not only about using async and await.

Real asynchronous systems require coordination.

You need to answer:

Who is allowed to execute?

When are they allowed to execute?

How many can execute simultaneously?

What happens when execution fails?

What happens when time changes?

What happens when the process pauses?

How do we know the system has actually completed?

Once you start thinking about these questions, the problem moves from simply writing code to designing a reliable system.

And that's exactly why this type of task is valuable for understanding backend engineering and distributed-system concepts.

Source Code

Interested in exploring the implementation?

👉 View the complete Call Campaign Simulator on GitHub

The repository contains the complete TypeScript implementation, including concurrency control, retry logic, scheduling, daily limits, timezone support, and an injected clock for testability.

Top comments (0)