Building an Automated Safety Timer Worker in Node.js

Building an Automated Safety Timer Worker in Node.js: Preventing Race Conditions with Atomic State Transitions

The Stakes: Why Meeting Timers Need Authoritative Backends

When building CasualMeet, one of our core safety promises is the Meeting Safety Timer.

Before heading out to meet someone from the internet, a user arms a timer for, say, 60 minutes. While the meetup is ongoing, they can extend it if things are going well, or tap “I’m Safe” once they leave.

[ Active / Extended ]  ──(User taps "I'm Safe")───────►  [ Safe ]
        │
        ├──(User adds +15 mins)───────────────────────►  [ Extended ]
        │
        └──(Timer reaches 00:00 without check-in)─────►  [ Expired ] ──► [ Trigger SOS Dispatch ]

If the countdown hits zero without any response from the user, the system assumes they may be incapacitated or in distress. It must automatically escalate:

  1. Creating an authoritative Emergency SOS Incident.
  2. Dispatching SMS alerts with GPS coordinates to their trusted emergency contacts via carrier gateways (Fast2SMS & Twilio).
  3. Broadcasting live alerts over WebSockets to both the user and creator admin moderation dashboards.

The frontend client cannot be trusted to trigger this escalation. If a user’s phone battery dies, their network drops, or the device is confiscated, the client app is dead. The backend must be the sole authority that monitors and executes the escalation.

However, building this in Node.js reveals subtle concurrency bugs and race conditions. In this article, I’ll walk through how naive implementations fail in production, and how we engineered an autonomous, race-condition-proof timer worker using atomic state transitions in MongoDB.


1. Why setTimeout() Fails in Production

The most common impulse when building delayed jobs in Node.js is to use in-memory timers:

// ❌ NAIVE APPROACH: Do NOT do this in production
setTimeout(async () => {
  await triggerSosEscalation(userId);
}, durationMinutes * 60 * 1000);

While this works in a local test script, it collapses in production for three critical reasons:

  1. Process Restarts & Deployments Wipe Memory: Whenever you redeploy your container, restart PM2, or the server crashes, all in-memory timeouts are permanently lost.
  2. Horizontal Scaling & Multiple Instances: If you run multiple instances behind a load balancer, an in-memory timer created on Server A cannot be cleared or extended if the user’s subsequent request lands on Server B.
  3. Drift & Memory Overhead: Scheduling thousands of long-running setTimeout handles over hours causes event-loop timer drift and bloats process memory.

The Golden Rule: All safety-critical states must be persisted in the database with explicit expiration timestamps (expiresAt), and evaluated by a state-driven worker.


2. Defining the State Machine & Database Schema

In our Mongoose schema (MeetingTimer.ts), a timer moves through strict state transitions:

export type TimerStatus = 'active' | 'safe' | 'extended' | 'expired' | 'cancelled';

export interface IMeetingTimer extends Document {
  userId: mongoose.Types.ObjectId;
  meetWithUserId?: mongoose.Types.ObjectId;
  locationName: string;
  meetupLocation?: {
    type: 'Point';
    coordinates: [number, number];
  };
  durationMinutes: number;
  startedAt: Date;
  expiresAt: Date;
  checkSentAt?: Date;
  resolvedAt?: Date;
  status: TimerStatus;
}

Essential Database Indexes

Because a background worker inspects expiring timers every few seconds, we must avoid full-collection table scans. We define compound indexes on:

// 1. For instant user lookups
MeetingTimerSchema.index({ userId: 1, status: 1 });

// 2. High-throughput index for our background expiration worker
MeetingTimerSchema.index({ expiresAt: 1, status: 1 });

3. The Race Condition Trap: “Check-Then-Act”

Suppose our background worker runs every 5 seconds. A naive inspection loop looks like this:

// ⚠️ DANGEROUS: Classic Check-Then-Act Race Condition
const expiredTimers = await MeetingTimer.find({
  status: { $in: ['active', 'extended'] },
  expiresAt: { $lte: new Date() }
});

for (const timer of expiredTimers) {
  // If the user taps "I'm Safe" right HERE, between the find and update:
  timer.status = 'expired';
  await timer.save();
  await dispatchEmergencyAlerts(timer);
}

The Scenario:

  1. At 10:00:00.000, the user reaches their car and opens the app to tap “I’m Safe”.
  2. At 10:00:00.050, the background worker queries MongoDB and finds the timer because expiresAt just passed.
  3. At 10:00:00.100, the user’s HTTP request arrives: PUT /api/timers/:id/safe. The route marks the timer status as 'safe' and saves it.
  4. At 10:00:00.150, the worker resumes, blindly overwrites the status back to 'expired', and fires off emergency SMS messages to the user’s family!

False emergency alerts destroy user trust and waste real carrier SMS credits.


4. The Solution: Atomic Compare-and-Swap (findOneAndUpdate)

To make this immune to race conditions, we use MongoDB’s atomic Compare-and-Swap (CAS) pattern via findOneAndUpdate.

Instead of checking in memory and saving later, the transition from active/extended to expired occurs in a single database operation:

// ✅ ATOMIC TRANSITION: Guaranteed single winner
const timer = await MeetingTimer.findOneAndUpdate(
  { 
    _id: candidate._id, 
    status: { $in: ['active', 'extended'] } // Only match if NOT already resolved
  },
  {
    $set: {
      status: 'expired',
      checkSentAt: new Date(),
    },
  },
  { new: true }
);

// If the user already marked it 'safe' or cancelled it, timer is null!
if (!timer) {
  continue; // Skip silently — the race was won by the user
}

Why This Works:

  • If the user taps “I’m Safe” even one millisecond before the worker executes this update, the timer’s status becomes 'safe'.
  • When the worker attempts findOneAndUpdate, the filter { status: { $in: ['active', 'extended'] } } fails to match.
  • MongoDB returns null, and the worker cleanly aborts without sending any emergency alerts.

5. Defense-in-Depth: Strict SOS Idempotency

Even with atomic updates, we apply an additional safety layer: Idempotency checks on the SosEvent collection.

Before creating an emergency incident and invoking third-party SMS APIs (Twilio / Fast2SMS), we verify that an active incident doesn’t already exist for this user or timer:

const existingSos = await SosEvent.findOne({
  $or: [
    { triggeredTimerId: timer._id },
    { userId: timer.userId, status: 'active' },
  ],
});

if (existingSos) {
  console.warn(`[TimerWorker] Active SOS incident already exists for user ${timer.userId}.`);
  continue;
}

This guarantees that even in edge cases (such as a database replica lag or temporary network partitions), duplicate dispatches are impossible.


6. The Complete Worker Implementation (timerWorker.ts)

Here is the production-tested worker logic running in our backend:

import { MeetingTimer } from '../models/MeetingTimer.js';
import { SosEvent } from '../models/SosEvent.js';
import { dispatchEmergencyAlerts } from '../services/dispatchService.js';
import { emitToUser, emitToAdmins } from '../socket.js';
import { sendNotification } from '../services/notificationService.js';

let intervalId: NodeJS.Timeout | null = null;
let isRunning = false; // Prevents overlapping poll cycles

export async function checkExpiredTimers() {
  if (isRunning) return { processedCount: 0, sosCreatedCount: 0 };

  isRunning = true;
  let processedCount = 0;
  let sosCreatedCount = 0;

  try {
    const now = new Date();

    // 1. Fetch batch of expired candidates using indexed fields
    const candidates = await MeetingTimer.find({
      status: { $in: ['active', 'extended'] },
      expiresAt: { $lte: now },
    }).limit(50);

    for (const candidate of candidates) {
      // 2. Atomic state transition: Prevents race conditions with user actions
      const timer = await MeetingTimer.findOneAndUpdate(
        { _id: candidate._id, status: { $in: ['active', 'extended'] } },
        {
          $set: {
            status: 'expired',
            checkSentAt: now,
          },
        },
        { new: true }
      );

      if (!timer) continue; // User resolved it concurrently; safely skip

      processedCount++;

      // 3. Idempotency check: Guard against duplicate active SOS records
      const existingSos = await SosEvent.findOne({
        $or: [
          { triggeredTimerId: timer._id },
          { userId: timer.userId, status: 'active' },
        ],
      });

      if (existingSos) {
        emitToUser(timer.userId.toString(), 'timer_expired', {
          timerId: timer._id,
          sosId: existingSos._id,
        });
        continue;
      }

      // 4. Create authoritative SOS incident
      const sos = await SosEvent.create({
        userId: timer.userId,
        source: 'timer_expired',
        location: timer.meetupLocation,
        locationName: timer.locationName || 'Timer Auto-Escalation Location',
        status: 'active',
        triggeredTimerId: timer._id,
        adminNotified: true,
      });

      sosCreatedCount++;

      // 5. Trigger multi-carrier SMS dispatch (Twilio / Fast2SMS)
      try {
        const dispatchResults = await dispatchEmergencyAlerts(sos);
        const delivered = dispatchResults.filter((r) => r.status === 'sent' || r.status === 'simulated').length;
        sos.contactsNotified = delivered;
        sos.smsSent = delivered > 0;
        sos.lastDispatchAt = new Date();
        await sos.save();
      } catch (err: any) {
        console.error(`[TimerWorker] Alert dispatch failed for SOS ${sos._id}:`, err.message);
      }

      // 6. Broadcast real-time WebSocket events to User and Admin rooms
      emitToUser(timer.userId.toString(), 'timer_expired', { timerId: timer._id, sosId: sos._id });
      emitToUser(timer.userId.toString(), 'sos_triggered', sos);
      emitToAdmins('sos_triggered', sos);

      // 7. Persist notification
      await sendNotification({
        recipientId: timer.userId,
        actor: { _id: timer.userId, name: 'CasualMeet Safety Engine', username: 'safety' },
        type: 'safety_timer',
        title: 'Safety Timer Expired',
        message: `Your meeting timer for "${timer.locationName}" expired. Emergency escalation initiated.`,
        targetType: 'user',
        targetId: timer.userId,
      });
    }
  } catch (err: any) {
    console.error('[TimerWorker] Cycle error:', err.message);
  } finally {
    isRunning = false;
  }

  return { processedCount, sosCreatedCount };
}

// Initialize worker on server startup
export function initTimerWorker(intervalMs = 5000): void {
  if (intervalId) return;
  console.log(`⏱️ Initializing Server Safety Timer Worker (${intervalMs}ms)...`);
  intervalId = setInterval(async () => {
    await checkExpiredTimers();
  }, intervalMs);
}

7. Operational Best Practices: Handling Overlap & Server Lifecycles

Re-entrancy Protection (isRunning Lock)

If an SMS gateway experiences slow response times, a worker cycle could theoretically take longer than the 5-second interval. Without a re-entrancy lock, a second interval would fire while the first is still processing, causing duplicate batches.

The simple if (isRunning) return; guard ensures that only one worker cycle executes at any given moment per Node.js process.

Graceful Shutdown

When rolling out new code or terminating the server, background workers must cleanly stop to avoid hanging database connections:

export function stopTimerWorker(): void {
  if (intervalId) {
    clearInterval(intervalId);
    intervalId = null;
    console.log('⏱️ Stopped Server Safety Timer Worker.');
  }
}

// In server.ts:
process.on('SIGTERM', () => {
  stopTimerWorker();
  process.exit(0);
});

Summary & Key Takeaways

  1. Never trust setTimeout for safety or mission-critical workflows. Persist state and deadlines in your primary database.
  2. Compound indexes on { expiresAt: 1, status: 1 } allow the worker to quickly fetch expired records without loading non-expired timers into memory.
  3. Use Atomic Compare-and-Swap (findOneAndUpdate) to prevent check-then-act race conditions between automated workers and real-time user inputs.
  4. Enforce idempotency before external I/O. Always check for pre-existing active incidents before hitting rate-limited or paid third-party APIs like SMS gateways.

With this architecture in place, our safety timers can reliably safeguard users on real-world meetups, even during server restarts, network blips, or sudden traffic spikes.

How I Built Real-Time Chat in React with Socket.IO · knowabteverything.com

How to Turn a React (Vite) App into an Android APK with Capacitor · knowabteverything.com

Leave a Comment