Short answer: use an external cron service to call a small public webhook, enqueue the media cleanup, and return immediately. Pick an embedded timer only when the process is continuously running, one replica owns the schedule, and a missed run is acceptable. The external trigger adds a network hop and operating overhead, but it keeps scheduling outside the web process and makes each daily attempt visible.
| Option | Pick it when | Latency | Cost and operational catch |
|---|---|---|---|
| External cron to public webhook | The cleanup is daily, the web tier may restart or scale, and operators need an attempt record | One HTTP hop before enqueueing | Another component to configure and monitor |
| Embedded process timer | One long-lived process owns the job and occasional delay or loss is tolerable | No trigger network hop | Cheap to start; ownership becomes difficult with replicas |
| Queue-native scheduled delivery | The queue is already the control plane and the team operates it | Direct path into work dispatch | More queue policy and recovery configuration |
For a media service deleting expired preview files and then sending a daily report email, I would choose the first row. The cleanup is batch work, so shaving milliseconds from its start is less valuable than separating it from request lifetime.
Keep the webhook boring.
Should a daily report email cron service call a public webhook endpoint?
Yes, when the endpoint is a trigger rather than the workplace. The request should authenticate the caller, derive a stable key for the intended daily run, enqueue a command, and respond. It should not scan the media catalog, delete objects, calculate the report, or wait for an email provider. That boundary answers the Node.js and Express setup question while avoiding the main architectural trap: turning a scheduler timeout into a cleanup timeout.
Picture the flow in words: clock -> authenticated webhook -> durable queue -> cleanup worker -> report outbox -> email sender. Beside that line, place metrics for accepted triggers, queue age, cleanup duration, deleted item count, and email outcome. An alert watches missing daily completion rather than merely watching webhook uptime. A fast 202 proves only that work was accepted; it doesn't prove that cleanup or delivery finished.
Use one logical run key such as media-cleanup:2026-08-22. If the scheduler retries the same intended run, the queue boundary or worker can recognize the key and avoid producing a second report. Don't derive that identity only from request arrival time. A retry just after midnight can otherwise look like a new job. The scheduler should send the intended date explicitly, and the receiver should validate its format and permitted window.
Authentication matters because the endpoint is public. A dedicated bearer secret is enough for the compact example below, provided it is stored outside source control, compared before any work is queued, rotated deliberately, and sent only over HTTPS. Teams that need replay resistance can use signed requests with a timestamp, but that adds key management and clock-skew policy. I'm not sure that complexity pays off for every internal media system; the threat model and existing gateway controls decide it.
Pick an external trigger when restarts and replicas are normal
An external trigger is the clearer choice when application instances are replaceable. The schedule has one owner outside the replica set, while every healthy instance can accept the same authenticated command. Deployment does not reset a local timer. Scaling from one instance to four does not intentionally create four daily reports.
The catch is real: there is another control plane, another credential, and a billable action in some services. It is not suitable when the environment cannot expose or securely route a webhook. In that case, prefer a queue-native schedule or a platform job that invokes the same command handler without public ingress. The architecture stays recognizable even though the first arrow changes.
External scheduling also gives observability a clean starting event. Record a structured log with runId, scheduledFor, and acceptedAt; increment an accepted-trigger counter; then let the worker emit its own start, completion, duration, and item-count signals. Correlation is the important bit. Without the same run ID on both sides, a green webhook chart can sit next to a missing report and tell you almost nothing. If admission is rate-limited and returns HTTP 429, the caller should respect a Retry-After header when one is present rather than hammering the endpoint. MDN documents both the status and that header. After the command reaches a queue, repeated processing failures should have a bounded receive policy and a dead-letter destination; AWS documents dead-letter queues as a way to isolate messages that were not successfully processed. A dead-letter queue isn't success storage. Alert on it, inspect it, and define who can redrive a message.
Retries need restraint.
Pick an embedded timer only with one explicit owner
An embedded timer can be the easiest setup for a prototype running as one persistent process. There is no public ingress and no trigger hop. For a low-stakes report, those are useful savings.
Stop there if the process sleeps, restarts freely, or runs multiple replicas. A timer in each replica has ambiguous ownership; a timer in an interrupted process has no memory of the missed instant unless you build that memory. You can add leader election, a durable lease, catch-up logic, and run history, but then the supposedly tiny timer has become a scheduler.
This option is still legitimate for a single-purpose worker where one supervisor guarantees a single active instance and the team explicitly accepts delayed execution after downtime. Alert on the age of the last completed cleanup. If the business later decides the report must arrive within a fixed window, move the clock outside the process before adding replicas.
Document that acceptance.
Latency is the main advantage, but be precise about which latency matters. Removing one HTTP request may start the worker slightly sooner; it does not make scanning ten thousand media records or sending the email faster. For a once-daily cleanup, queue age and worker duration usually give a more actionable picture than trigger response time alone. Your mileage may vary when the schedule runs every few seconds.
Implement the webhook as an admission controller
The endpoint below is deliberately narrow. All code is TypeScript. The queue and run store are interfaces, so the HTTP path does not know which infrastructure implements them. The handler validates a scheduler-owned token, checks an ISO calendar date, claims the run key, publishes a command, and returns.
import express, { Request, Response } from "express";
type CleanupCommand = {
runId: string;
scheduledFor: string;
requestedAt: string;
};
interface CommandQueue {
publish(command: CleanupCommand): Promise<void>;
}
interface RunStore {
claim(runId: string): Promise<boolean>;
release(runId: string): Promise<void>;
}
export function createApp(queue: CommandQueue, runs: RunStore) {
const app = express();
app.use(express.json({ limit: "8kb" }));
app.post("/jobs/media-cleanup", async (req: Request, res: Response) => {
const expected = process.env.SCHEDULER_TOKEN;
const supplied = req.get("authorization");
if (!expected || supplied !== `Bearer ${expected}`) {
res.sendStatus(401);
return;
}
const scheduledFor = req.body?.scheduledFor;
if (typeof scheduledFor !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(scheduledFor)) {
res.status(400).json({ error: "scheduledFor must be YYYY-MM-DD" });
return;
}
const runId = `media-cleanup:${scheduledFor}`;
if (!(await runs.claim(runId))) {
res.status(202).json({ runId, state: "already-accepted" });
return;
}
try {
await queue.publish({
runId,
scheduledFor,
requestedAt: new Date().toISOString(),
});
res.status(202).json({ runId, state: "accepted" });
} catch (error) {
await runs.release(runId);
throw error;
}
});
return app;
}
The claim and publish operations need a deliberate consistency design. In a production system, use a durable store and queue, then choose a transactional outbox or an equivalent mechanism that prevents an accepted run from disappearing between those two operations. The interface does not magically make the pair atomic.
Good.
It makes the unresolved boundary visible in review.
The worker owns the slow path. It loads expired media in bounded pages, deletes according to the repository's retention policy, records per-item outcomes, and writes the report request to an outbox. The email sender consumes that outbox separately, so a temporary delivery limit does not repeat successful media deletion.
type CleanupResult = { deleted: number; retained: number; failed: number };
interface MediaCleaner {
removeExpired(scheduledFor: string): Promise<CleanupResult>;
}
interface ReportOutbox {
add(report: CleanupCommand & CleanupResult): Promise<void>;
}
export async function handleCleanup(
command: CleanupCommand,
cleaner: MediaCleaner,
reports: ReportOutbox,
): Promise<void> {
const startedAt = Date.now();
console.info("media_cleanup_started", {
runId: command.runId,
scheduledFor: command.scheduledFor,
});
const result = await cleaner.removeExpired(command.scheduledFor);
await reports.add({ ...command, ...result });
console.info("media_cleanup_completed", {
runId: command.runId,
durationMs: Date.now() - startedAt,
...result,
});
}
Test the boundary, not the clock. An endpoint test should prove that bad authentication publishes nothing, malformed dates publish nothing, duplicate run IDs still return an accepted state without a second command, and a valid request publishes exactly one command. A worker test should replay the same command and verify the retention operation and report outbox follow the chosen idempotency contract. In staging, invoke the endpoint with a fixed date and trace that run ID through logs, queue metrics, cleanup metrics, and the report outbox.
Deployment needs one more guard: create the queue, run store, secrets, dashboards, and alerts before enabling the schedule. Then trigger a controlled run. Only after its completion signal and report are visible should the recurring rule be enabled. This before/after sequence prevents a live clock from discovering missing infrastructure for you.
Limits and the final decision rule
Choose the public-webhook cron design for the daily media cleanup when batch latency tolerates a network hop and you value isolated scheduling, replica-safe ownership, and a traceable attempt boundary. Stick with an embedded timer when one supervised process is genuinely the permanent owner and the cost of an extra scheduling component outweighs the impact of a delayed or missed report. Choose queue-native scheduling when public ingress is forbidden and the queue is already an operated dependency.
No option removes the need for idempotency, bounded retries, durable run state, and an alert on missing completion. Those controls are the actual system.
The clock just starts it.
Top comments (0)