# How OpenSEO's Rank Tracking Scheduler Prevents Schedule Drift

> OpenSEO's rank tracking scheduler prevents schedule drift by using persistent anchor timestamps, ensuring future check times remain accurate even with delayed jobs.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-09-09

---

**OpenSEO eliminates schedule drift by storing a persistent anchor timestamp (`next_check_at`) that advances by fixed calendar intervals rather than using the actual execution time, ensuring delayed or failed jobs never shift future check times.**

Rank tracking requires consistent scheduling to produce reliable SEO trend data. The `every-app/open-seo` repository implements a sophisticated anchor-based scheduling system that guarantees checks occur at predictable intervals regardless of processing delays or transient failures.

## The Schedule Anchor Mechanism

Instead of calculating "now + interval" for the next run, OpenSEO maintains a **`next_check_at`** field for every rank tracking configuration. This immutable anchor serves as the reference point for all subsequent schedule calculations. The core logic resides in [`src/shared/rank-tracking.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/rank-tracking.ts), specifically within the `computeNextCheckAt` function.

## The Drift Prevention Algorithm

The algorithm guarantees that the scheduled time remains anchored to the original cadence, even when real-world execution lags behind.

### Interval-Based Advancement

When the scheduler invokes `computeNextCheckAt`, it enters a `while` loop that advances the existing anchor by the configured interval until the result exceeds the current timestamp. As implemented in lines 68-88 of [`src/shared/rank-tracking.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/rank-tracking.ts):

```typescript
while (nextDate.getTime() <= now) {
  // Advance by 1 day, 7 days, or next month-end
}

```

This loop ensures that a weekly check originally scheduled for Monday will always calculate the next Monday as its subsequent run, even if the current execution occurs on Wednesday. The system never drifts forward by the delay duration.

### Time-of-Day Preservation

For **daily** and **weekly** intervals, the function preserves the original hour and minute components from the existing anchor (lines 10-16). This means a check scheduled for 05:30 UTC maintains that specific time-of-day across all future iterations, regardless of when the actual processing occurs.

### Initial Anchor Establishment

New configurations without existing anchors receive a randomized hour between **04:00 and 09:00 UTC** (and month-end day for monthly schedules). This one-time randomization, found in lines 96-104, establishes a stable reference point that prevents thundering herds while maintaining long-term consistency.

## Failure-Resilient Schedule Updates

The cron worker in [`src/server/features/rank-tracking/services/scheduledRankChecks.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/rank-tracking/services/scheduledRankChecks.ts) explicitly protects schedule integrity during errors. Lines 28-38 contain logic that prevents updating `next_check_at` when plan checks or workflow starts fail. By not overwriting the anchor during failures, the configuration remains due for the next scheduler tick without shifting the intended timeline.

## Implementation Examples

The following TypeScript demonstrates how the anchor system handles a delayed weekly check:

```typescript
// Compute the next check for a weekly config that was supposed to run on Monday
// but actually ran on Wednesday. The anchor stays on Monday at the same hour.
import { computeNextCheckAt } from "@/shared/rank-tracking";

const next = computeNextCheckAt(
  "weekly",                     // interval
  "2024‑09-02T05:30:00.000Z"   // previous next_check_at (Monday 05:30 UTC)
);
// → "2024‑09-09T05:30:00.000Z" (next Monday)

```

For new monthly configurations, the function establishes the initial anchor:

```typescript
// Scheduling a brand‑new monthly tracker – a random hour is chosen once
import { computeNextCheckAt } from "@/shared/rank-tracking";

const first = computeNextCheckAt("monthly"); // no previous anchor
// → e.g. "2024‑09-30T07:12:00.000Z" (end of current month at a random hour)

```

## Summary

- **Persistent anchors**: OpenSEO stores `next_check_at` rather than calculating from execution time, creating a fixed reference point.
- **Loop-based advancement**: The `while` loop in `computeNextCheckAt` advances by fixed intervals (daily, weekly, monthly) until reaching the future, preventing cumulative drift.
- **Temporal consistency**: Original hour and minute values are preserved for daily and weekly schedules, maintaining predictable time-of-day execution.
- **Error isolation**: Failed checks in [`scheduledRankChecks.ts`](https://github.com/every-app/open-seo/blob/main/scheduledRankChecks.ts) do not update the anchor, ensuring transient errors never shift the schedule.

## Frequently Asked Questions

### What causes schedule drift in automated rank tracking?

Schedule drift occurs when systems calculate the next execution time as "current time + interval" rather than using a fixed reference point. If a job scheduled for Monday runs on Wednesday due to delays, the "now + 7 days" approach pushes all future checks to Wednesday, creating a permanent shift. OpenSEO prevents this by advancing a fixed anchor rather than using variable execution times.

### How does OpenSEO's anchor-based scheduling differ from cron-based approaches?

Traditional cron expressions determine the next run based on when the scheduler wakes up, which shifts if the system is down. OpenSEO's `computeNextCheckAt` function instead calculates all possible future slots from the original `next_check_at` anchor and jumps forward until finding a future slot, ensuring the cadence remains locked to the original schedule regardless of system availability.

### What happens when a rank check fails to execute?

According to the error handling logic in [`src/server/features/rank-tracking/services/scheduledRankChecks.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/rank-tracking/services/scheduledRankChecks.ts), failures in `runScheduledRankChecks` skip the database update for `next_check_at`. The configuration remains with its current anchor timestamp, causing it to be re-evaluated on the next scheduler tick without advancing the schedule. Once successful, the scheduler advances the anchor normally.

### Where does OpenSEO establish the initial check time for new configurations?

The `computeNextCheckAt` function in [`src/shared/rank-tracking.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/rank-tracking.ts) (lines 96-104) handles initial anchor creation. When called without a previous anchor, it generates a random UTC hour between 04:00 and 09:00 and, for monthly intervals, selects the last day of the current month. This randomization distributes load while creating a stable reference for all future calculations.