Back to blog
FILE 0x77·HEARTBEATS WERE POLLUTING MY ACTIVITY TABLE

Heartbeats were polluting my activity table

April 23, 2026 · aws, dynamodb, lambda, side-project

The phone in a side-project safety app posts a stationary_heartbeat every few minutes when it's not moving — so the server knows the device is alive even when there's no user activity. The original implementation wrote those heartbeats into the same DynamoDB activities table as real user events. That broke a downstream sleep-window learner that was supposed to detect quiet gaps. With heartbeats every five minutes there were no quiet gaps.

What was happening

The "last activity" query in the inactivity cron was reading the most recent row of checkonmine_activities and computing a gap from it. Heartbeats were rows in that table. So the gap was almost always five minutes, and the sleep-window learner saw a 24/7 firehose of low-amplitude events instead of any natural overnight quiet.

The throttle in api.php did one rate-limit check per event_type, which meant heartbeats also stomped on the throttle for real events.

What I found

The two kinds of writes have different shapes:

Putting both in one table conflates two access patterns and breaks both. The fix is to split the table.

The fix

New table:

aws dynamodb create-table \
  --table-name checkonmine_device_pings \
  --attribute-definitions \
      AttributeName=user_id,AttributeType=S \
      AttributeName=timestamp,AttributeType=S \
  --key-schema \
      AttributeName=user_id,KeyType=HASH \
      AttributeName=timestamp,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

aws dynamodb update-time-to-live \
  --table-name checkonmine_device_pings \
  --time-to-live-specification "Enabled=true,AttributeName=ttl"

And the router branch in api.php:

if ($event === 'stationary_heartbeat') {
    $table = 'checkonmine_device_pings';
    $item['ttl'] = time() + 90 * 86400;
} else {
    $table = 'checkonmine_activities';
}
$db->put($table, $item);

The critical-health-alert pipeline still runs for heartbeats — the payload (battery, GPS quality, etc.) is evaluated in-memory before the row is written. So the cheap-storage move doesn't lose anything.

Verified with md5 hash checks across all 10 Lambda functions post-deploy: zero drift between functions or from the source repo.

What I'd do differently

The original "write everything to one table" decision was made before the sleep-window learner existed. The learner was bolted on later, looked at the same table, and silently produced garbage. That's the lesson: when you add a feature that depends on a property of existing data, write down what property you're betting on. "Quiet stretches in activities correspond to user sleep" was a hidden assumption that broke the day someone added heartbeats.

If I'd been on the ball at the time, the cleanest pattern would have been an is_heartbeat boolean on each row with a GSI that excludes heartbeats. Same effect, fewer table-management chores, and the TTL goes on the same table. I might still migrate that direction.