Ploy
Ploy
Features

Timers

Schedule durable one-shot and recurring timers that fire your worker at a specified time.

Timers

Ploy Timers let you schedule durable timers that call your worker's timer handler at a specified time. Timers survive restarts and are guaranteed to fire at least once. They can be one-shot or recurring with a fixed interval. They're ideal for delayed actions, reminders, subscription renewals, periodic cleanup, and any task that needs to run at a specific time or on a schedule.

Configuration

Add a timer binding in your ploy.yaml:

ploy.yaml
kind: worker
build: pnpm build
out: dist
timer:
  TIMER: default

The key (TIMER) is the binding name available in your worker's env. The value (default) is the timer store identifier.

Run ploy types to generate TypeScript types:

env.d.ts
import type { TimerBinding } from "@meetploy/types";

export interface Env {
	TIMER: TimerBinding;
}

Basic Example

src/index.ts
export default {
	async fetch(request, env) {
		const url = new URL(request.url);

		if (url.pathname === "/schedule") {
			// Schedule a timer to fire in 60 seconds
			await env.TIMER.set("reminder-123", Date.now() + 60_000, {
				userId: "user_abc",
				message: "Your trial is expiring!",
			});

			return Response.json({ scheduled: true });
		}

		return new Response("Timer Worker");
	},

	// Called when a timer fires
	async timer(event, env) {
		console.log("Timer fired:", event.id);
		console.log("Payload:", event.payload);
	},
} satisfies Ploy;

Timer Binding API

set(id, scheduledTime, options?) - Schedule a Timer

Schedules a new timer on this binding. If a timer with the same ID already exists on the same timer binding, it is replaced.

The third argument can be either a plain payload value (for simple one-shot timers) or an options object with payload and intervalMs fields.

// Simple one-shot timer with a payload
await env.TIMER.set("order-followup", new Date("2025-12-01T09:00:00Z"), {
	orderId: "order_123",
});

// One-shot timer without payload
await env.TIMER.set("reminder", Date.now() + 3600_000);

// Recurring timer using options object
await env.TIMER.set("cleanup", Date.now() + 60_000, {
	payload: { type: "cache" },
	intervalMs: 5 * 60_000, // Every 5 minutes
});
  • id - A unique identifier for the timer. Use this to update or cancel it later.
  • scheduledTime - A Date object or Unix timestamp in milliseconds.
  • options - Either a plain JSON-serializable payload, or an options object:
    • payload - Optional JSON-serializable data delivered with the timer when it fires.
    • intervalMs - Optional interval in milliseconds. When set, the timer automatically reschedules after each fire.

get(id) - Get Timer Details

Returns the timer details, or null if the timer doesn't exist or has already fired.

const timer = await env.TIMER.get("order-followup");
if (timer) {
	console.log(timer.id); // "order-followup"
	console.log(timer.scheduledTime); // Unix timestamp (ms)
	console.log(timer.payload); // { orderId: "order_123" }
	console.log(timer.intervalMs); // undefined for one-shot, number for recurring
}

delete(id) - Cancel a Timer

Cancels a scheduled timer so it won't fire. For recurring timers, this stops all future executions.

await env.TIMER.delete("order-followup");

Timer Handler

The timer export on your worker is called when a timer fires:

async timer(event, env, ctx) {
  console.log({
    id: event.id,               // The timer identifier
    scheduledTime: event.scheduledTime,  // When it was scheduled to fire (ms)
    payload: event.payload,     // The payload you provided
    intervalMs: event.intervalMs, // Interval in ms (if recurring)
  });
}

Timers that throw an error are automatically retried after a short backoff. Make your timer handler idempotent to handle duplicate deliveries.

Recurring Timers

Set intervalMs to create a recurring timer that automatically reschedules after each fire. The next fire time is calculated as previousFireTime + intervalMs, ensuring consistent intervals regardless of handler execution time.

Every 5 Minutes

await env.TIMER.set("metrics-report", Date.now(), {
	intervalMs: 5 * 60_000,
});

Every Hour with Payload

await env.TIMER.set("hourly-sync", Date.now(), {
	payload: { source: "external-api" },
	intervalMs: 60 * 60_000,
});

Daily Cleanup

// Start tomorrow at midnight, repeat daily
const tomorrow = new Date();
tomorrow.setHours(24, 0, 0, 0);

await env.TIMER.set("daily-cleanup", tomorrow, {
	payload: { retentionDays: 30 },
	intervalMs: 24 * 60 * 60_000,
});

Stop a Recurring Timer

Use delete to cancel a recurring timer and prevent future executions:

await env.TIMER.delete("metrics-report");

Common Patterns

Delayed Notifications

async fetch(request, env) {
  const { userId, message, delayMinutes } = await request.json();

  await env.TIMER.set(
    `notify-${userId}-${Date.now()}`,
    Date.now() + delayMinutes * 60_000,
    { userId, message },
  );

  return Response.json({ scheduled: true });
}

Subscription Renewal Reminders

// When a user subscribes, schedule a reminder before renewal
await env.TIMER.set(
	`renewal-${userId}`,
	renewalDate.getTime() - 3 * 24 * 3600_000, // 3 days before
	{ userId, plan: "pro" },
);

// If the user cancels, remove the reminder
await env.TIMER.delete(`renewal-${userId}`);

Rescheduling Timers

Since set replaces existing timers with the same ID, you can reschedule by calling set again:

// Reschedule to a later time
await env.TIMER.set("task-deadline", newDeadline.getTime(), { taskId: "123" });

Multiple Timer Bindings

You can configure multiple timer stores for different use cases:

ploy.yaml
timer:
  TIMER: default
  NOTIFICATIONS: notifications
// General-purpose timers
await env.TIMER.set("cleanup", Date.now() + 3600_000);

// Notification-specific timers
await env.NOTIFICATIONS.set("reminder", Date.now() + 60_000, {
	channel: "email",
});

Next Steps

  • Queues - Send messages for async processing
  • Workflows - Orchestrate multi-step processes
  • State - Add durable key-value storage

How is this guide?

Last updated on