How It Works

Cloudflare Workers supports scheduled triggers via cron syntax defined in wrangler.toml. When the trigger fires, Cloudflare calls the scheduled() handler exported from the worker instead of the normal fetch() handler. It runs entirely server-side — no HTTP request, no response, no client involved.

src/index.ts — scheduled handler
export default {
  fetch: app.fetch,

  async scheduled(_event, env, ctx): Promise<void> {
    ctx.waitUntil(
      // Step 1 — flush KV counters into D1
      syncCountersToDb(env)
        .then((result) => logger.info("Cron: counter sync complete", result))
        .catch((err)  => logger.error("Cron: counter sync failed", { error: String(err) }))

        // Step 2 — recalculate all tool rankings with fresh D1 counts
        .then(() => recalculateAllRankings(env))
        .then(({ updated }) => logger.info("Cron: ranking recalculation complete", { updated }))
        .catch((err) => logger.error("Cron: ranking recalculation failed", { error: String(err) }))
    );
  },
};
â„šī¸ ctx.waitUntil() keeps the Worker alive until the promise chain resolves, even though there is no HTTP response to send. Without it, the Worker would terminate immediately after the handler returns.

Schedule

Cron expressionMeaningWhere to change
*/30 * * * *Every 30 minuteswrangler.toml → [triggers] → crons
wrangler.toml
[triggers]
crons = ["*/30 * * * *"]

# Other useful values:
# "*/15 * * * *"  — every 15 minutes
# "0 * * * *"     — every hour
# "0 2 * * *"     — every day at 02:00 UTC
# "0 2 * * 0"     — every Sunday at 02:00 UTC
âš ī¸ After changing the cron string, run wrangler deploy to push the new schedule. The change takes effect immediately on the next cron tick.

Step 1 — Counter Sync (KV → D1)

Views and clicks are buffered in Cloudflare KV on each request (fast write, no D1 pressure). The cron job is the only thing that moves those counts into D1 permanently. This step always runs first so that the ranking recalculation in Step 2 uses up-to-date counts.

KV namespace: COUNTERS v:{toolId} = "12" ← 12 views buffered since last sync c:{toolId} = "3" ← 3 clicks buffered since last sync For each key: 1. Read count from KV val = 12 2. Delete KV key immediately prevents double-count on retry 3. D1: viewCount += 12 ✓ success → synced++ ✗ failure → restore KV key → failed++
1

List all buffered keys

kv.list({ prefix: "v:" }) and kv.list({ prefix: "c:" }) — fetches all tool IDs that have pending counts. Tools with no buffered events have no KV key and are skipped entirely.

2

Read and immediately delete the KV key

The key is deleted before the D1 write. This prevents double-counting if the cron fires again before the D1 write completes (e.g. retries). The count value is held in memory for the next step.

3

Increment D1 counter

prisma.tool.update({ data: { viewCount: { increment: count } } }) — adds the buffered value atomically. Not a SET — it increments, so any counts that arrived since the KV read are safe.

4

Restore on D1 failure

If the D1 update throws, the KV key is written back with the saved count. The next cron run will retry it. No counts are lost.

Return value logged after Step 1
{
  "views":  14,   // number of tool IDs with buffered view counts
  "clicks": 6,    // number of tool IDs with buffered click counts
  "synced": 19,   // successful D1 updates
  "failed": 1    // D1 failures (KV key restored for retry)
}

Step 2 — Ranking Recalculation

After counters are flushed into D1, recalculateAllRankings() re-scores every non-archived tool using the updated counts. This ensures the listing order always reflects the latest engagement data.

1

Fetch all non-archived tools in batches of 100

Uses cursor-based pagination to avoid D1 query limits. Selects only the fields needed for the formula: upvoteCount, clickCount, viewCount, rating, reviewCount, featured, verified, trending, publishedAt.

2

Compute rankScore for each tool

Applies the ranking formula — computeRankScore(row). The recency boost (30 / (daysSincePublished + 1)) naturally decays on every run, so older tools gradually give way to newer ones with similar engagement.

3

Write rankScore back to D1

prisma.tool.update({ data: { rankScore: score } }) — one update per tool. After all batches complete, invalidateToolCache() is called to bump the KV cache version so the next listing request fetches fresh data from D1.

Ranking formula (computeRankScore)
rankScore =
  upvoteCount  × 4.0    // strongest signal
  + clickCount × 2.5    // outbound intent
  + viewCount  × 0.05   // dampened — easy to game
  + rating × reviewCount × 3.0
  + (featured  ? 50 : 0)
  + (verified  ? 25 : 0)
  + (trending  ? 30 : 0)
  + 30 / (daysSincePublished + 1)  // recency decay
â„šī¸ rankScore is also updated immediately after individual events (upvote, review, rating, tool creation/update). The cron recalc is the catch-all that keeps view and click counts — which are KV-buffered — reflected in rankings without waiting for another individual event.

Failure Handling

Failure scenarioWhat happensData lost?
D1 write fails on counter syncKV key restored with original count. Next cron run retries the flush.No
KV restore also failsCount for that tool is logged as failed and dropped.Possible (rare)
Ranking recalc fails for a toolThat tool keeps its previous rankScore. Next cron run retries all tools.No
Counter sync step throws entirely.catch() logs the error; ranking recalc is still attempted (chained with .then() after catch).Possible
Worker CPU limit exceededCloudflare terminates the handler. Partial flush may have occurred — remaining KV keys are retried next run.No — KV keys still present

Testing Locally

Start dev server with scheduled support
wrangler dev --test-scheduled
Trigger the cron manually
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
Trigger once in production (Cloudflare dashboard)
Workers → your worker → Triggers tab → "Test" button next to the cron

Changing the Schedule

Only change the cron string in wrangler.toml — no code change needed. Redeploy to apply.

Cron stringFrequencyWhen to use
*/15 * * * *Every 15 minHigh traffic — counts visible sooner in rankings
*/30 * * * *Every 30 minDefault — good balance
0 * * * *Every hourLow traffic — fewer D1 writes
0 2 * * *Daily at 02:00 UTCMinimal traffic — rankings update once a day