How hipages Scaled Lead Credits: The Actor Model Explained Simply
How hipages eliminated database locks, race conditions, and scaling bottlenecks in our lead credit system—using the Actor Model, a pattern inspired by banks and fintechs.
How hipages Scaled Lead Credits: The Actor Model Explained Simply
At hipages, we faced a classic scaling challenge: thousands of tradies compete for job leads, and each claim must instantly deduct credits from their account. Under heavy load, our old system struggled—causing slowdowns, errors, and unhappy users.
The root cause? Traditional database locks couldn't keep up with the concurrency demands. We needed a new approach.
The solution: We adopted the Actor Model, a pattern used by banks and fintechs to handle high-volume transactions without locks or race conditions. Here’s how we transformed our system for reliability and scale.
What is Concurrency, and Why is it Hard?
When many users try to update the same data at once (like credits in an account), systems can run into "race conditions"—where the outcome depends on the unpredictable order of operations. This leads to bugs, lost data, or system crashes.
The Problem: Why Database Locks Failed Us
Old approach:
// ❌ Problematic approach: database locks
// Each claim locks the account row, checks balance, deducts credits, saves, unlocks.
// Under load, this caused slowdowns and deadlocks.
Problems:
- Database lock contention when many tradies claimed leads at once
- Deadlocks and timeouts during peak hours
- Poor performance and frustrated users
During busy periods:
- 40% of transactions timed out
- Database CPU spiked to 100%
- Tradies missed out on leads due to system errors
The Actor Model: A Simple Analogy
Imagine a post office:
- Each mailbox (actor) has its own key and only one person can open it at a time.
- Letters (messages) are delivered and processed one by one.
- No two people can change the contents of a mailbox at the same time.
In software, the Actor Model works the same way:
- Each account is managed by its own "actor" (like a mailbox)
- All changes happen through messages, processed in order
- No shared state, no locks, no race conditions
Why do banks and fintechs use it?
- No locks needed: Each account is managed by a single actor
- High throughput: Actors process messages concurrently
- Fault tolerance: Actor failures don't affect others
- Auditability: Every state change is a message
How We Solved It with the Actor Model
New approach:
- Each tradie account is managed by a dedicated actor
- All credit changes are handled by sending messages to that actor
- Each actor processes one message at a time, so no two changes can conflict
Result: No more locks, no more deadlocks, and the system scales easily.
Key Results
| Before (Locks) | After (Actor Model) |
|---|---|
| 40% timeouts | 99.9% success |
| Slow at peak | Sub-50ms response |
| Deadlocks | Zero deadlocks |
| Hard to debug | Clear audit trail |
We redesigned our system with these key components:
// Core actor system interfaces
interface CreditMessage {
type: 'DEBIT' | 'CREDIT' | 'INQUIRY' | 'RESERVE' | 'RELEASE'
amount: number
transactionId: string
metadata?: any
}
interface CreditActor {
id: string
balance: number
reservedAmount: number
processMessage(message: CreditMessage): Promise<CreditResponse>
}
interface CreditResponse {
success: boolean
newBalance?: number
error?: string
transactionId: string
}
The Credit Actor Implementation
Each tradie's credit account is managed by a dedicated actor:
import { Injectable, Logger } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { EventEmitter2 } from '@nestjs/event-emitter'
@Injectable()
export class TradieCreditActor {
private readonly logger = new Logger(TradieCreditActor.name)
private messageQueue: CreditMessage[] = []
private processing = false
constructor(
private readonly tradieId: string,
@InjectRepository(CreditTransaction)
private readonly transactionRepo: Repository<CreditTransaction>,
private readonly eventEmitter: EventEmitter2
) {}
async sendMessage(message: CreditMessage): Promise<CreditResponse> {
return new Promise((resolve) => {
// Add response callback to message
const messageWithCallback = {
...message,
resolve
}
this.messageQueue.push(messageWithCallback)
this.processQueue()
})
}
private async processQueue(): Promise<void> {
if (this.processing || this.messageQueue.length === 0) {
return
}
this.processing = true
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()!
try {
const response = await this.handleMessage(message)
message.resolve(response)
} catch (error) {
this.logger.error(`Error processing message for tradie ${this.tradieId}`, error)
message.resolve({
success: false,
error: error.message,
transactionId: message.transactionId
})
}
}
this.processing = false
}
private async handleMessage(message: CreditMessage): Promise<CreditResponse> {
const currentState = await this.getCurrentState()
switch (message.type) {
case 'DEBIT':
return await this.processDebit(message, currentState)
case 'CREDIT':
return await this.processCredit(message, currentState)
case 'RESERVE':
return await this.processReservation(message, currentState)
case 'RELEASE':
return await this.processRelease(message, currentState)
case 'INQUIRY':
return await this.processInquiry(currentState)
default:
throw new Error(`Unknown message type: ${message.type}`)
}
}
private async processDebit(
message: CreditMessage,
currentState: CreditState
): Promise<CreditResponse> {
const availableBalance = currentState.balance - currentState.reservedAmount
if (availableBalance < message.amount) {
// Log insufficient funds attempt
await this.logTransaction({
tradieId: this.tradieId,
type: 'DEBIT_FAILED',
amount: message.amount,
transactionId: message.transactionId,
reason: 'INSUFFICIENT_FUNDS',
balanceBefore: currentState.balance,
balanceAfter: currentState.balance
})
return {
success: false,
error: 'Insufficient credits',
transactionId: message.transactionId
}
}
// Process the debit
const newBalance = currentState.balance - message.amount
await this.logTransaction({
tradieId: this.tradieId,
type: 'DEBIT',
amount: message.amount,
transactionId: message.transactionId,
balanceBefore: currentState.balance,
balanceAfter: newBalance,
metadata: message.metadata
})
// Emit event for other systems
this.eventEmitter.emit('credit.debited', {
tradieId: this.tradieId,
amount: message.amount,
newBalance,
transactionId: message.transactionId,
metadata: message.metadata
})
return {
success: true,
newBalance,
transactionId: message.transactionId
}
}
private async processReservation(
message: CreditMessage,
currentState: CreditState
): Promise<CreditResponse> {
const availableBalance = currentState.balance - currentState.reservedAmount
if (availableBalance < message.amount) {
return {
success: false,
error: 'Insufficient credits for reservation',
transactionId: message.transactionId
}
}
// Reserve the amount
const newReservedAmount = currentState.reservedAmount + message.amount
await this.logTransaction({
tradieId: this.tradieId,
type: 'RESERVE',
amount: message.amount,
transactionId: message.transactionId,
balanceBefore: currentState.balance,
balanceAfter: currentState.balance,
reservedBefore: currentState.reservedAmount,
reservedAfter: newReservedAmount,
metadata: message.metadata
})
return {
success: true,
newBalance: currentState.balance,
transactionId: message.transactionId
}
}
private async getCurrentState(): Promise<CreditState> {
// Get current balance from the latest transaction
const latestTransaction = await this.transactionRepo.findOne({
where: { tradieId: this.tradieId },
order: { createdAt: 'DESC' }
})
if (!latestTransaction) {
return { balance: 0, reservedAmount: 0 }
}
// Calculate reserved amount from active reservations
const activeReservations = await this.transactionRepo
.createQueryBuilder('t')
.where('t.tradieId = :tradieId', { tradieId: this.tradieId })
.andWhere('t.type = :type', { type: 'RESERVE' })
.andWhere('t.status = :status', { status: 'ACTIVE' })
.getMany()
const reservedAmount = activeReservations.reduce(
(sum, reservation) => sum + reservation.amount,
0
)
return {
balance: latestTransaction.balanceAfter,
reservedAmount
}
}
private async logTransaction(transaction: Partial<CreditTransaction>): Promise<void> {
const creditTransaction = this.transactionRepo.create({
...transaction,
createdAt: new Date(),
status: 'COMPLETED'
})
await this.transactionRepo.save(creditTransaction)
}
}
Distributed Actor Management with Dapr
Critical Challenge: Multiple Pods, Single Actor Per Account
Our initial implementation had a serious flaw: what happens when multiple Kubernetes pods each create their own CreditActorManager? Each pod could instantiate its own actor for the same tradie account, breaking the fundamental Actor Model guarantee of single-actor-per-account.
The Solution: Dapr Actors
We use Dapr Actors to ensure only one actor instance exists per tradie account across the entire cluster:
import { DaprServer, ActorId, ActorRuntime } from '@dapr/dapr'
// Dapr ensures only one instance of this actor exists cluster-wide
export class TradieCreditActor {
private balance: number = 0
private reservedAmount: number = 0
private readonly actorId: ActorId
constructor(actorId: ActorId) {
this.actorId = actorId
}
// Dapr automatically routes messages to the correct actor instance
async debitCredits(amount: number, transactionId: string): Promise<CreditResponse> {
// Load current state from Dapr state store
await this.loadState()
const availableBalance = this.balance - this.reservedAmount
if (availableBalance < amount) {
return {
success: false,
error: 'Insufficient credits',
transactionId
}
}
// Update balance
this.balance -= amount
// Persist state to Dapr state store
await this.saveState()
// Log transaction
await this.logTransaction({
type: 'DEBIT',
amount,
transactionId,
balanceBefore: this.balance + amount,
balanceAfter: this.balance
})
return {
success: true,
newBalance: this.balance,
transactionId
}
}
async reserveCredits(amount: number, transactionId: string): Promise<CreditResponse> {
await this.loadState()
const availableBalance = this.balance - this.reservedAmount
if (availableBalance < amount) {
return {
success: false,
error: 'Insufficient credits for reservation',
transactionId
}
}
this.reservedAmount += amount
await this.saveState()
// Set timer to auto-release reservation after 5 minutes
await this.setReservationTimer(transactionId, amount)
return {
success: true,
newBalance: this.balance,
transactionId
}
}
async releaseReservation(amount: number, transactionId: string): Promise<void> {
await this.loadState()
this.reservedAmount = Math.max(0, this.reservedAmount - amount)
await this.saveState()
// Cancel the auto-release timer
await this.cancelReservationTimer(transactionId)
}
// Dapr actor state management
private async loadState(): Promise<void> {
const state = await this.getActorState()
this.balance = state.balance || 0
this.reservedAmount = state.reservedAmount || 0
}
private async saveState(): Promise<void> {
await this.setActorState({
balance: this.balance,
reservedAmount: this.reservedAmount,
lastUpdated: new Date().toISOString()
})
}
// Dapr actor timers for automatic reservation cleanup
private async setReservationTimer(transactionId: string, amount: number): Promise<void> {
await this.registerActorTimer(
`reservation_${transactionId}`,
'releaseExpiredReservation',
{ amount, transactionId },
'5m' // 5 minutes
)
}
private async cancelReservationTimer(transactionId: string): Promise<void> {
await this.unregisterActorTimer(`reservation_${transactionId}`)
}
// Called by Dapr when reservation timer expires
async releaseExpiredReservation(data: { amount: number, transactionId: string }): Promise<void> {
await this.loadState()
this.reservedAmount = Math.max(0, this.reservedAmount - data.amount)
await this.saveState()
console.log(`Auto-released expired reservation: ${data.transactionId}`)
}
}
Dapr Actor Registration and Client
// Register the actor with Dapr runtime
const daprServer = new DaprServer({
serverHost: '127.0.0.1',
serverPort: '50001'
})
// Register actor type
await ActorRuntime.registerActor(TradieCreditActor)
// Start Dapr server
await daprServer.start()
Distributed Actor Manager
The Actor Manager now uses Dapr to ensure distributed actor management:
@Injectable()
export class CreditActorManager {
private readonly logger = new Logger(CreditActorManager.name)
private readonly daprClient: DaprClient
constructor(
@InjectRepository(CreditTransaction)
private readonly transactionRepo: Repository<CreditTransaction>,
private readonly eventEmitter: EventEmitter2
) {
this.daprClient = new DaprClient({
daprHost: '127.0.0.1',
daprPort: '3500'
})
}
// No local actor map - Dapr handles actor location and lifecycle
private async getActorProxy(tradieId: string): Promise<ActorProxy> {
return this.daprClient.actor.getActorProxy(
'TradieCreditActor', // Actor type
tradieId // Actor ID (unique per tradie)
)
}
async claimLead(
tradieId: string,
leadId: string,
creditCost: number
): Promise<LeadClaimResult> {
const transactionId = this.generateTransactionId()
try {
// Get Dapr actor proxy - Dapr ensures we get the right instance
const actorProxy = await this.getActorProxy(tradieId)
// Step 1: Reserve credits via Dapr actor
const reservationResult = await actorProxy.invoke(
'reserveCredits',
creditCost,
transactionId
)
if (!reservationResult.success) {
return {
success: false,
error: reservationResult.error,
transactionId
}
}
// Step 2: Attempt to claim the lead
const leadClaimResult = await this.attemptLeadClaim(leadId, tradieId, transactionId)
if (leadClaimResult.success) {
// Step 3a: Convert reservation to debit
await actorProxy.invoke('releaseReservation', creditCost, transactionId)
await actorProxy.invoke('debitCredits', creditCost, `${transactionId}_debit`)
return {
success: true,
leadClaim: leadClaimResult.leadClaim,
transactionId
}
} else {
// Step 3b: Release the reservation
await actorProxy.invoke('releaseReservation', creditCost, transactionId)
return {
success: false,
error: leadClaimResult.error,
transactionId
}
}
} catch (error) {
this.logger.error(`Lead claim failed for tradie ${tradieId}`, error)
// Ensure reservation is released on any error
try {
const actorProxy = await this.getActorProxy(tradieId)
await actorProxy.invoke('releaseReservation', creditCost, transactionId)
} catch (cleanupError) {
this.logger.error(`Failed to cleanup reservation`, cleanupError)
}
return {
success: false,
error: 'System error during lead claim',
transactionId
}
}
}
private async attemptLeadClaim(
leadId: string,
tradieId: string,
transactionId: string
): Promise<LeadClaimAttemptResult> {
// This is where we check if the lead is still available
// and create the claim record atomically
try {
const leadClaim = await this.dataSource.transaction(async (manager) => {
const lead = await manager.findOne(Lead, {
where: { id: leadId, status: 'AVAILABLE' }
})
if (!lead) {
throw new Error('Lead no longer available')
}
// Check if tradie already claimed this lead
const existingClaim = await manager.findOne(LeadClaim, {
where: { leadId, tradieId }
})
if (existingClaim) {
throw new Error('Lead already claimed by this tradie')
}
// Create the claim
const claim = manager.create(LeadClaim, {
leadId,
tradieId,
transactionId,
claimedAt: new Date(),
status: 'ACTIVE'
})
await manager.save(claim)
// Update lead status if needed (e.g., if it's now fully claimed)
const claimCount = await manager.count(LeadClaim, {
where: { leadId, status: 'ACTIVE' }
})
if (claimCount >= lead.maxClaims) {
lead.status = 'CLAIMED'
await manager.save(lead)
}
return claim
})
return { success: true, leadClaim }
} catch (error) {
return { success: false, error: error.message }
}
}
private generateTransactionId(): string {
return `txn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
Integration with the Lead Claiming API
Here's how the Actor system integrates with our NestJS API:
@Controller('api/leads')
@UseGuards(AuthGuard, RBACGuard)
export class LeadController {
constructor(
private readonly creditActorManager: CreditActorManager,
private readonly leadService: LeadService,
private readonly logger: Logger
) {}
@Post(':leadId/claim')
@ApiOperation({ summary: 'Claim a lead using credits' })
@ApiResponse({ status: 200, description: 'Lead claimed successfully' })
@ApiResponse({ status: 400, description: 'Insufficient credits or lead unavailable' })
async claimLead(
@Param('leadId') leadId: string,
@Request() req: AuthenticatedRequest
): Promise<LeadClaimResponse> {
const tradieId = req.user.id
try {
// Get lead details and credit cost
const lead = await this.leadService.getLeadById(leadId)
if (!lead) {
throw new NotFoundException('Lead not found')
}
if (lead.status !== 'AVAILABLE') {
throw new BadRequestException('Lead is no longer available')
}
const creditCost = await this.leadService.calculateCreditCost(lead, tradieId)
// Use actor system to claim the lead
const result = await this.creditActorManager.claimLead(
tradieId,
leadId,
creditCost
)
if (result.success) {
// Emit event for notifications, analytics, etc.
this.eventEmitter.emit('lead.claimed', {
leadId,
tradieId,
creditCost,
transactionId: result.transactionId
})
return {
success: true,
message: 'Lead claimed successfully',
leadClaim: result.leadClaim,
creditsUsed: creditCost,
transactionId: result.transactionId
}
} else {
return {
success: false,
error: result.error,
transactionId: result.transactionId
}
}
} catch (error) {
this.logger.error(`Lead claim failed for tradie ${tradieId}`, error)
throw error
}
}
@Get('credits/balance')
@ApiOperation({ summary: 'Get current credit balance' })
async getCreditBalance(
@Request() req: AuthenticatedRequest
): Promise<CreditBalanceResponse> {
const tradieId = req.user.id
const actor = this.creditActorManager.getActor(tradieId)
const inquiry = await actor.sendMessage({
type: 'INQUIRY',
amount: 0,
transactionId: `inquiry_${Date.now()}`
})
return {
balance: inquiry.newBalance || 0,
availableBalance: inquiry.availableBalance || 0
}
}
}
Dapr Deployment Configuration
# Kubernetes deployment with Dapr sidecar
apiVersion: apps/v1
kind: Deployment
metadata:
name: credit-service
spec:
replicas: 3 # Multiple pods, but Dapr ensures single actor per account
template:
metadata:
annotations:
dapr.io/enabled: "true"
dapr.io/app-id: "credit-service"
dapr.io/app-port: "3000"
dapr.io/config: "dapr-config"
spec:
containers:
- name: credit-service
image: credit-service:latest
ports:
- containerPort: 3000
---
# Dapr configuration for actor placement
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
name: dapr-config
spec:
features:
- name: Actor.Placement.ActorDeactivationScanInterval
enabled: true
- name: Actor.Placement.ActorIdleTimeout
enabled: true
Why Dapr Actors Solve the Distribution Problem
Before Dapr (Problematic):
- Pod A creates
TradieCreditActorfor tradie "123" - Pod B also creates
TradieCreditActorfor tradie "123" - Both actors process messages independently
- Race conditions and inconsistent state
With Dapr Actors:
- Dapr Placement Service ensures only one actor instance per ID across the cluster
- Actor "123" lives on exactly one pod at any time
- All messages for tradie "123" are routed to that single actor
- If the pod fails, Dapr recreates the actor on another pod with preserved state
Key Benefits:
- Guaranteed Uniqueness: Only one actor per tradie ID cluster-wide
- Automatic Failover: Actors move between pods seamlessly
- State Persistence: Actor state survives pod restarts
- Load Distribution: Dapr distributes actors across available pods
- Automatic Cleanup: Idle actors are deactivated to save resources
Advanced Features
Credit Expiration with Dapr Actor Timers
With Dapr, we use actor timers for automatic cleanup instead of cron jobs:
// Inside TradieCreditActor class
async reserveCredits(amount: number, transactionId: string): Promise<CreditResponse> {
// ... reservation logic ...
// Set Dapr actor timer for automatic cleanup
await this.registerActorTimer(
`reservation_${transactionId}`, // Timer name
'releaseExpiredReservation', // Method to call
{ amount, transactionId }, // Data to pass
'5m' // Duration (5 minutes)
)
return { success: true, newBalance: this.balance, transactionId }
}
// This method is called by Dapr when the timer fires
async releaseExpiredReservation(data: { amount: number, transactionId: string }): Promise<void> {
await this.loadState()
// Only release if reservation still exists
if (this.reservedAmount >= data.amount) {
this.reservedAmount -= data.amount
await this.saveState()
console.log(`Auto-released expired reservation: ${data.transactionId}`)
}
}
// Cancel timer when reservation is manually released
async releaseReservation(amount: number, transactionId: string): Promise<void> {
await this.loadState()
this.reservedAmount = Math.max(0, this.reservedAmount - amount)
await this.saveState()
// Cancel the auto-release timer
await this.unregisterActorTimer(`reservation_${transactionId}`)
}
Benefits of Dapr Actor Timers:
- Per-actor timers: Each actor manages its own cleanup timers
- Fault tolerant: Timers survive pod restarts and actor migrations
- No external dependencies: No need for separate cron jobs or schedulers
- Precise timing: Timers are tied to specific reservations, not global cleanup
Monitoring and Metrics
We added comprehensive monitoring to track actor performance:
@Injectable()
export class CreditMetricsService {
constructor(
@InjectMetric('credit_transactions_total')
private readonly transactionCounter: Counter<string>,
@InjectMetric('credit_processing_duration')
private readonly processingHistogram: Histogram<string>,
@InjectMetric('actor_queue_size')
private readonly queueSizeGauge: Gauge<string>
) {}
recordTransaction(type: string, success: boolean, duration: number): void {
this.transactionCounter.inc({
type,
status: success ? 'success' : 'failure'
})
this.processingHistogram.observe({ type }, duration)
}
updateQueueSize(actorId: string, size: number): void {
this.queueSizeGauge.set({ actor_id: actorId }, size)
}
}
Installing Dapr for Actor Support
# Install Dapr CLI
curl -fsSL https://raw.githubusercontent.com/dapr/cli/master/install/install.sh | /bin/bash
# Initialize Dapr in Kubernetes
dapr init -k
# Install Dapr Node.js SDK
npm install @dapr/dapr
Comparison: Local vs Distributed Actors
| Aspect | Local Actors (In-Memory) | Dapr Actors (Distributed) |
|---|---|---|
| Actor Uniqueness | ❌ Per-pod only | ✅ Cluster-wide guaranteed |
| Failover | ❌ Lost on pod restart | ✅ Automatic migration |
| State Persistence | ❌ In-memory only | ✅ Durable state store |
| Load Distribution | ❌ Manual sharding | ✅ Automatic placement |
| Scaling | ❌ Complex coordination | ✅ Seamless horizontal scaling |
| Development Complexity | ✅ Simple to start | ⚠️ Requires Dapr setup |
| Operational Overhead | ✅ Minimal | ⚠️ Dapr infrastructure |
Results and Benefits
Performance Improvements
After implementing Dapr Actors:
- 99.9% success rate for lead claims (up from 60%)
- Sub-50ms response times even during peak load
- Zero database deadlocks related to credit management
- 10x throughput improvement during busy periods
- 100% actor uniqueness guaranteed across all pods
- Zero state inconsistencies from duplicate actors
Operational Benefits
- Simplified debugging: Each transaction has a clear audit trail
- Better monitoring: Actor-level metrics provide detailed insights
- Easier scaling: Dapr automatically distributes actors across pods
- Fault isolation: Actor failures don't cascade to other accounts
- Automatic failover: Actors seamlessly move between healthy pods
- Persistent state: Actor state survives pod restarts and deployments
Business Impact
- Increased tradie satisfaction: Reliable lead claiming experience
- Higher conversion rates: More successful transactions during peak times
- Reduced support tickets: Fewer credit-related issues
- Better cash flow: More predictable credit usage patterns
Should You Use the Actor Model?
Use it if you have:
- High concurrency
- Financial or credit-like transactions
- Need for audit trails
Not needed for:
- Simple CRUD apps
- Low-traffic systems
Best Practices and Lessons Learned
1. Message Design
Keep messages simple and idempotent:
// ✅ Good: Simple, clear message
interface DebitMessage {
type: 'DEBIT'
amount: number
transactionId: string
reason: string
}
// ❌ Bad: Complex message with side effects
interface ComplexMessage {
type: 'DEBIT_AND_NOTIFY_AND_LOG'
amount: number
shouldSendEmail: boolean
logLevel: string
// ... too many responsibilities
}
2. Error Handling
Always handle failures gracefully:
private async handleMessage(message: CreditMessage): Promise<CreditResponse> {
try {
return await this.processMessage(message)
} catch (error) {
// Log error but don't crash the actor
this.logger.error(`Message processing failed`, error)
return {
success: false,
error: 'Processing failed',
transactionId: message.transactionId
}
}
}
3. State Persistence
Ensure actor state is always recoverable:
async restoreActorState(tradieId: string): Promise<CreditState> {
// Rebuild state from transaction log
const transactions = await this.transactionRepo.find({
where: { tradieId },
order: { createdAt: 'ASC' }
})
return this.calculateStateFromTransactions(transactions)
}
When to Use the Actor Model
The Actor Model is ideal when you have:
- High concurrency requirements
- Complex state management
- Need for audit trails
- Distributed system architecture
- Financial or credit-like transactions
It's not suitable for:
- Simple CRUD operations
- Low-concurrency applications
- Systems requiring immediate consistency across actors
Conclusion
By switching to the Actor Model, hipages turned a scaling bottleneck into a competitive advantage. If you’re struggling with concurrency, consider this proven approach—used by banks and fintechs for decades.
Want to discuss concurrency patterns or actor systems? Connect with me on LinkedIn or email me.