Back to blog
FILE 0x43·FIVE IDENTICAL SCHEDULES, THIRTEEN SMS IN TEN MINUTES

Five identical schedules, thirteen SMS in ten minutes

April 25, 2026 · aws, lambda, postmortem, idempotency

09:33 to 09:43 on a Friday morning: thirteen SMS messages, several push notifications, all from my own safety app, all to me. The trigger was five identical "Check-in" schedules firing at 09:00:00 in parallel.

What was happening

Five active rows in checkin_schedules for the same user, all named "Check-in", all with schedule_time=09:00:00. They had been created the previous afternoon in a 20-minute window — most likely a test script or a curl loop, definitely not the iOS app (the iOS create path was 404'ing at the time and silently failing).

The cron fan-out:

Plus 6 push notifications: one per duplicate createCheckinRequest call plus the pre-alert.

The contact list for tiers 1 and 2 included me as my own emergency contact (a separate UX bug — the app lets you list yourself). So the escalations all landed back on my phone.

What I found

Three independent failures, none of them individually catastrophic, all together pretty loud:

  1. POST /checkin/schedules had no idempotency check. Five identical creates created five rows.
  2. The cron had no per-user-per-tick fan-out cap. Five expirations for the same user all escalated simultaneously to all contacts.
  3. The emergency contact CRUD allowed self-listing. A normal escalation became a self-SMS event.

The fix

Three layered defenses, committed together:

// 1. Idempotent POST /checkin/schedules
$existing = $db->query('checkin_schedules', [
    'user_id' => $userId,
    'name' => $name,
    'schedule_time' => $scheduleTime,
    'is_active' => true,
]);
if ($existing) {
    return ['schedule_id' => $existing[0]['id'], 'duplicate' => true];
}

// 2. Per-user-per-cron-cycle dedup in cron_services.php
if (isset($triggeredUserKeys[$key])) {
    markScheduleTriggered($schedule); // prevent re-fire later same day
    continue;
}
$triggeredUserKeys[$key] = true;

// 3. Per-user escalation cap in processExpiredCheckins
if (isset($escalatedUsers[$req['user_id']])) {
    resolveRequest($req, 'sibling_request_already_escalated_this_cycle');
    continue;
}
$escalatedUsers[$req['user_id']] = true;

For the self-contact issue: short-term I just deleted my own contact row; longer-term the right answer is a warning at contact creation time, which is still open.

What I'd do differently

The duplicate-create part is one of those bugs that's obvious in retrospect. Any user-facing POST that creates a uniquely-named resource should be idempotent on the unique attributes by default, not by exception. I keep relearning that.

The cron fan-out cap is the more interesting lesson. The escalation code was written assuming "one request per user per window" as an invariant and never checked it. Code that depends on an invariant should either enforce it or assert it — silently trusting it leads to thirteen-SMS Fridays.