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
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
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"
]
The system should not randomly reorder customers.
Working Hours
Calls are only allowed during a configured time window.
Example:
09:00 → 17:00
If the current time is:
08:30
the campaign must wait.
If the current time is:
18:00
the campaign must also wait until the next valid working period.
Concurrency Control
Suppose:
maxConcurrentCalls = 3;
The system can have at most three active calls:
Call A → active
Call B → active
Call C → active
Call D → waiting
Call E → waiting
This is a classic concurrency limiting problem.
Daily Call Limit
Suppose:
maxDailyMinutes = 120;
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
Total:
30 + 40 + 20 + 30 = 120 minutes
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);
}
But this ignores almost every important requirement.
We need to coordinate:
Time
+
Concurrency
+
Retries
+
Daily Limits
+
Pause / Resume
+
Timezone
+
State Management
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";
The state machine looks like:
start()
│
▼
┌───────┐
│ idle │
└───┬───┘
│
▼
┌─────────┐
┌────▶│ running │──────┐
│ └────┬────┘ │
│ │ │
resume() pause() completed
│ │ │
│ ▼ ▼
└──────┌────────┐ ┌───────────┐
│ paused │ │ completed │
└────────┘ └───────────┘
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
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;
}
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;
}>;
This is an important software design principle:
Separate business logic from infrastructure logic.
The Campaign class manages:
Scheduling
Concurrency
Retries
Limits
State
The CallHandler manages:
Actual call operation
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
}
}
Testing becomes difficult because every test would depend on the external telephony system.
Instead:
class Campaign {
constructor(
private callHandler: CallHandler
) {}
}
Now tests can inject:
const fakeCallHandler: CallHandler = async () => ({
answered: true,
durationMs: 5000
});
This gives us deterministic tests.
8. The Clock Abstraction
Time is another dependency.
A naive implementation might use:
Date.now();
setTimeout(...);
clearTimeout(...);
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;
}
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)
};
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 {}
}
Now tests don't have to wait for real time.
10. Working Hours
Suppose:
startTime = "09:00";
endTime = "17:00";
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
}
For example:
08:59 → false
09:00 → true
12:30 → true
16:59 → true
17:00 → false
Boundary conditions are important.
11. Waiting Until Working Hours
Suppose the campaign starts at:
07:30
and working hours are:
09:00 → 17:00
The campaign should not immediately call a customer.
Instead:
07:30
│
│ wait
▼
09:00
│
▼
Start calling
The scheduler needs to calculate:
delay = nextWorkingStart - currentTime;
Then:
clock.setTimeout(
() => processNext(),
delay
);
12. Concurrency Control
Concurrency is one of the most important parts of the system.
Suppose:
maxConcurrentCalls = 2;
and we have:
Customer A
Customer B
Customer C
Customer D
The system should produce:
Time →
A ────────────────
B ──────────
C ───────────────
D ──────────
But never:
A ───────
B ───────
C ─────── ❌
because that would mean three simultaneous calls.
13. Concurrency Counter
A simple mechanism is:
private activeCalls = 0;
Before starting a call:
if (
this.activeCalls >=
this.config.maxConcurrentCalls
) {
return;
}
When the call starts:
this.activeCalls++;
When it finishes:
this.activeCalls--;
This gives us a basic semaphore-like mechanism.
14. Semaphore Concept
A semaphore controls access to a limited resource.
If we have:
3 permits
then only three operations can execute simultaneously.
Initially:
Available permits = 3
Call A:
Available = 2
Call B:
Available = 1
Call C:
Available = 0
Call D:
WAIT
When A finishes:
Available = 1
D can now start.
This is exactly the type of resource management our campaign needs.
15. Daily Cap
Suppose:
maxDailyMinutes = 120;
We maintain:
private dailyMinutesUsed = 0;
When a call finishes:
const minutes =
durationMs / 60000;
this.dailyMinutesUsed += minutes;
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
and a call lasts:
10 minutes
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
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";
The application server might actually be running in:
UTC
The campaign's business rules should still use:
New York time
For example:
Server:
14:00 UTC
New York:
10:00
If working hours are:
09:00 → 17:00
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
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());
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
is therefore dangerous.
The correct approach is to use:
America/New_York
instead of manually calculating:
UTC - 5
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
The system shouldn't necessarily permanently fail Customer B.
Instead:
Attempt 1
│
▼
Failed
│
▼
Wait
│
▼
Attempt 2
If it fails again:
Attempt 2
│
▼
Failed
│
▼
Attempt 3
After the maximum retry count:
Permanently Failed
22. Retry Configuration
The configuration contains:
maxRetries: 2,
retryDelayMs: 3600000
This means the system can retry a failed call according to the configured retry policy.
The delay:
3600000 ms
equals:
1 hour
because:
1000 ms = 1 second
60 seconds = 1 minute
60 minutes = 1 hour
Therefore:
1000 × 60 × 60 = 3,600,000 ms
23. Retry Queue
Instead of immediately retrying:
await retry();
we can schedule it:
clock.setTimeout(
() => retryCustomer(customer),
retryDelayMs
);
The system can maintain:
private pendingRetries = 0;
When a retry is scheduled:
this.pendingRetries++;
When the retry starts:
this.pendingRetries--;
24. Exponential Backoff
A more advanced retry strategy is exponential backoff.
Instead of:
1 hour
1 hour
1 hour
we could use:
1 minute
2 minutes
4 minutes
8 minutes
The formula is:
delay =
baseDelay * Math.pow(2, attempt);
For example:
attempt 0 → 1 minute
attempt 1 → 2 minutes
attempt 2 → 4 minutes
attempt 3 → 8 minutes
This is commonly used in distributed systems.
25. Pause and Resume
The campaign supports:
campaign.pause();
and:
campaign.resume();
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
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
Whereas cancellation could mean:
Existing calls → terminate if possible
New calls → blocked
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;
}
This gives the outside world a snapshot of the campaign.
Example:
const status = campaign.getStatus();
console.log(status);
Possible output:
{
state: "running",
totalProcessed: 25,
totalFailed: 2,
activeCalls: 3,
pendingRetries: 1,
dailyMinutesUsed: 87.5
}
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
}
}
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;
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);
}
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
This can be represented as:
┌───────────────┐
│ Campaign │
│ running? │
└───────┬───────┘
│ yes
▼
┌───────────────┐
│ Working │
│ hours? │
└───────┬───────┘
│ yes
▼
┌───────────────┐
│ Concurrency │
│ available? │
└───────┬───────┘
│ yes
▼
┌───────────────┐
│ Daily cap │
│ available? │
└───────┬───────┘
│ yes
▼
START CALL
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();
}
}
The finally block is particularly important.
Whether the call succeeds or fails:
this.activeCalls--;
must happen.
Otherwise the campaign could become permanently stuck.
33. Why finally Matters
Bad implementation:
try {
await call();
activeCalls--;
} catch {
// activeCalls never decremented
}
If the call throws an exception:
activeCalls = 1
call throws
activeCalls stays 1
Eventually the system may believe that the concurrency limit has been reached forever.
Better:
try {
await call();
} finally {
activeCalls--;
}
This is a classic resource-cleanup pattern.
34. Handling Success
Suppose the call result is:
{
answered: true,
durationMs: 120000
}
We can calculate:
const minutes =
durationMs / 60000;
Then:
this.dailyMinutesUsed += minutes;
this.totalProcessed++;
The customer is now considered successfully processed.
35. Handling Failure
Suppose:
{
answered: false,
durationMs: 5000
}
The business rule can decide that the call should be retried.
Conceptually:
if (attempt < maxRetries) {
scheduleRetry();
} else {
totalFailed++;
}
This creates two possible paths:
Failure
│
├── retries remaining → Retry
│
└── no retries → Permanent failure
36. Attempt Tracking
Each customer needs retry information.
For example:
private retryAttempts =
new Map<string, number>();
When a customer fails:
const attempts =
this.retryAttempts.get(phoneNumber) ?? 0;
this.retryAttempts.set(
phoneNumber,
attempts + 1
);
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>
But in a real system, phone numbers might not be unique or stable.
A production system would usually have:
customerId
phoneNumber
For example:
interface Customer {
id: string;
phoneNumber: string;
}
Then retry state can use:
Map<CustomerId, RetryState>
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;
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 ─────────
The selection order remains:
A → B → C → D → E
while execution overlaps.
This distinction is very important.
39. Completion Detection
The campaign should only become:
"completed"
when there is no more work.
That usually means:
Customer queue empty
+
Active calls = 0
+
Pending retries = 0
Therefore:
if (
currentIndex >= customerList.length &&
activeCalls === 0 &&
pendingRetries === 0
) {
state = "completed";
}
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"
};
Suppose:
09:00
The campaign starts.
41. Initial Execution
Concurrency limit:
2
So:
Call 555-0001
Call 555-0002
are started.
The remaining customers wait:
555-0003
555-0004
42. First Call Finishes
Suppose:
555-0001
answered = true
duration = 10 minutes
Now:
dailyMinutesUsed = 10
activeCalls = 1
The scheduler can start:
555-0003
Now:
555-0002 → active
555-0003 → active
43. Failed Call
Suppose:
555-0002
answered = false
The system schedules a retry:
retry in 60 seconds
Status:
pendingRetries = 1
The campaign can continue processing other customers if capacity allows.
44. Retry
After the delay:
555-0002 → retry
If successful:
totalProcessed++
If it fails again:
attempt = 2
and the system checks whether another retry is allowed.
45. Daily Cap Reached
Suppose:
dailyMinutesUsed = 58
and a call finishes with:
duration = 5 minutes
Now:
dailyMinutesUsed = 63
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);
We can also calculate the start of the day:
const startOfDay =
now.startOf("day");
And the next day:
const nextDay =
now.plus({ days: 1 })
.startOf("day");
This is useful for daily cap calculations.
47. Calendar Day vs 24 Hours
An important distinction:
Daily cap
usually means a calendar day, not:
rolling 24-hour window
For example:
Monday 23:50 → Tuesday 00:10
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);
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()
we use:
clock.now()
Instead of:
setTimeout()
we use:
clock.setTimeout()
Now tests can control the clock.
49. Deterministic Testing
Suppose we want to test:
Retry after 1 hour
With a fake clock:
clock.advanceBy(60 * 60 * 1000);
We can simulate one hour instantly.
The test becomes:
Start
↓
Failure
↓
Schedule retry
↓
Advance clock 1 hour
↓
Retry executes
No real waiting is necessary.
This is an example of deterministic testing.
50. Testing Concurrency
We should test that:
activeCalls <= maxConcurrentCalls
at all times.
For:
maxConcurrentCalls = 3
we should never observe:
activeCalls = 4
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
};
};
Then assert:
expect(maximumObserved)
.toBeLessThanOrEqual(3);
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
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
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
and verify:
totalProcessed === 1
totalFailed === 0
Another test:
Attempt 1 → fail
Attempt 2 → fail
Attempt 3 → fail
should produce:
totalProcessed === 0
totalFailed === 1
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
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();
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);
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
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()
handles working hours.
getNextWorkingTime()
handles scheduling.
executeCall()
handles call execution.
scheduleRetry()
handles retries.
checkDailyLimit()
handles daily capacity.
checkCompletion()
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)
);
can create hundreds or thousands of simultaneous calls.
That violates:
maxConcurrentCalls
Mistake 2 — Using Recursive Calls Without Guards
Something like:
processNext();
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++;
must eventually be paired with:
activeCalls--;
even when an exception occurs.
Mistake 4 — Using Server Time for Business Rules
This is dangerous:
new Date();
without considering the configured campaign timezone.
Business time should be calculated using:
configured timezone
Mistake 5 — Treating Retry as a New Customer
A retry should not incorrectly increment:
totalProcessed
until the customer actually succeeds.
Retry attempts and successfully processed customers are different metrics.
59. Metrics vs Events
Another useful distinction is between:
Attempts
and:
Processed Customers
Suppose:
Customer A:
Attempt 1 → failed
Attempt 2 → failed
Attempt 3 → success
The system made:
3 attempts
but successfully processed:
1 customer
These should not be confused.
A production system might expose:
totalAttempts
totalProcessed
totalFailed
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
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
Or possibly:
Redis
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
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
Workers consume jobs.
Concurrency can then be controlled by:
Number of workers
+
Per-worker concurrency
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
Now the customer might receive two calls.
A production system therefore needs an idempotency strategy.
For example:
idempotencyKey =
`${campaignId}:${customerId}:${attempt}`;
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
Two workers check simultaneously:
Worker A → sees 5 minutes
Worker B → sees 5 minutes
Both decide:
Allowed
Then both consume:
5 minutes
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
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
Each customer is processed a finite number of times based on retry configuration.
If:
R = maximum retries
the maximum number of attempts is approximately:
N × (R + 1)
For example:
100 customers
2 retries
Maximum attempts:
100 × 3 = 300
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>
the memory requirement is approximately:
O(N)
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
where:
C = maxConcurrentCalls
Therefore the number of simultaneously active operations is bounded by:
O(C)
rather than:
O(N)
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
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
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
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
defines the resource boundary.
2. Time should be abstracted
Instead of tightly coupling the application to:
Date.now()
setTimeout()
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
5. State machines simplify lifecycle management
Explicit states such as:
idle
running
paused
completed
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
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
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)