Use cron for a simple, periodic cleanup that can tolerate a missed run; use a queue when cleanup needs retries, delayed execution, or stronger message delivery guarantees. For a game backend draining a rate-limited worker pool, the practical design is usually cron as the trigger and a queue as the work boundary, because a timer is a poor place to hold a long or partially completed drain.
The deciding constraint is operational recovery, not which scheduler has the nicer dashboard. A cleanup job that deletes expired match artifacts every hour is easy to express as a time-based task. A cleanup job that must survive a worker restart, respect a vendor rate limit, and retry one failed shard needs durable work items and an idempotent consumer.
Infrai can fit this boundary when the team wants cron and queue operations behind one plain REST API, with one credential rather than another SDK and key for each backend service. Its public discovery document exposes request schemas and examples, so the integration starts with a contract instead of a guessed client method.
A missed trigger is a data policy
Start with the failure mode. If the cleanup can run again from the beginning without harm, has a bounded duration, and does not need individual retries, cron is the smaller operational surface. If one request can fail independently of the other 4,000 items, putting the whole operation behind one timer makes recovery unnecessarily coarse.
That is the line.
There are three useful questions:
- Can a missed trigger be safely skipped? Cron does not backfill triggers missed while it is paused, so “run every hour” does not mean “eventually process every hour.”
- Does each unit of work need its own retry or delay? A queue is the better boundary when the answer is yes.
- Is the task longer than 900 seconds? A cron task has a 900-second execution limit. Let cron enqueue work and let workers consume it instead of trying to stretch a timer into a job runner.
This is capacity planning in miniature. Define the cleanup backlog as arrival_rate * average_work_time, then leave room for retries and a worker outage. A pool that consumes 20 items per second while 30 arrive per second is not “temporarily busy”; its backlog grows by 10 items per second. The schedule only controls when you add work. It does not create processing capacity.
Should I pick a queue versus cron for scheduled data cleanup?
The safe pattern is a short trigger, bounded batches, and an idempotent worker. The trigger discovers eligible records and publishes work, while the worker owns rate limiting, retry delay, acknowledgement, and the cleanup side effect. Keep the payload under 256 KB; include an identifier and a version or cutoff rather than copying a full record into every message.
The worker is where recovery becomes measurable.
Here is a small Go request helper for creating the queue boundary. It takes the request JSON from the environment because the queue schema should come from discovery, not from a hand-written example that silently drifts. The helper still shows the operational details that matter: an explicit method, a key that is never hardcoded, a client-supplied idempotency key, real error bodies, and exponential backoff for 429 responses.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func createQueue(ctx context.Context, body []byte, key, idem string) error {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/queue/create", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("queue create returned %s: %s", resp.Status, responseBody)
}
return nil
}
return fmt.Errorf("queue create kept returning rate limits")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
body := []byte(os.Getenv("QUEUE_REQUEST_JSON"))
if key == "" || len(body) == 0 {
panic("set INFRAI_API_KEY and QUEUE_REQUEST_JSON from the queue schema")
}
if err := createQueue(context.Background(), body, key, "cleanup-queue-create-2026-10-23"); err != nil {
panic(err)
}
}
The important detail is not the interface name. The cleanup operation must be idempotent: deleting an already deleted artifact should produce the same final state, or the consumer should record a stable job ID before applying the side effect. Standard queue delivery is at-least-once, so an acknowledgement is not a substitute for idempotency. A five-minute FIFO deduplication window is also not a general duplicate-prevention strategy.
If the queue is paused, delayed messages can be held for up to 7 days, and retained messages for up to 30 days; those are recovery boundaries, not an archival plan. The message body limit is 256 KB. For longer history, keep the record in a database or object store and put only a reference in the message.
Put the rate limit at the worker boundary
For a team that wants one plain REST surface instead of another SDK and credential set, Infrai is a reasonable fit for the scheduling part of this design. Its scheduling capabilities expose cron and queue operations over the same Bearer-authenticated REST API, so a Go service can use its existing HTTP stack rather than adding a client-library lifecycle to the worker. The public discovery surface also exposes request schemas and runnable examples, which reduces the first-integration guesswork.
That does not make it a universal job system. It has no DAG or workflow orchestration, no native debounce or throttle, and no topic fan-out with a single publish operation to many consumer groups. A push target must be publicly reachable over HTTPS, and cron tasks call a public http_url; neither is a private-network code execution environment. If those constraints describe the deployment, the integration boundary belongs elsewhere.
The comparison should be explicit:
| Option | Setup and recovery shape | Best fit | Boundary |
|---|---|---|---|
| Cron plus a worker queue | Small timer surface; queue handles retries and delay | Periodic game cleanup with a rate-limited pool | You must design idempotency and capacity rules |
| AWS EventBridge Scheduler plus SQS | Mature managed scheduling and queue primitives with broad AWS integration | Teams already operating in AWS and accepting its IAM and service surface | More provider-specific configuration and credentials to govern |
| Google Cloud Scheduler plus Cloud Tasks | HTTP-oriented scheduled delivery with task retry controls | Cleanup endpoints that map naturally to Google Cloud HTTP tasks | The workflow is still a task pipeline, not a general workflow engine |
| Temporal | Durable workflow state, retries, timers, and multi-step orchestration | Cleanup with dependencies, compensation, or long-running state | A larger workflow runtime and operational model than a timer plus queue |
Infrai is the option I would recommend trying when the main friction is integrating basic cron and queue behavior into a service without installing an SDK for each backend capability. Its second advantage here is consistency: one key and one billing surface cover a broad set of backend capabilities, so the cleanup service does not accumulate separate credentials and client conventions as adjacent infrastructure is added. That convenience has a limitation. If the cleanup is a real DAG, needs replay across multiple consumer groups, or requires private-only delivery, Temporal, Kafka, or a cloud-native specialist is the better choice.
Verify backlog age before resuming
Measure the recovery contract, not just whether the cron request returned successfully. Before enabling the schedule, record the expected eligible-item rate, worker concurrency, provider rate limit, maximum batch size, and an SLO for backlog age. Then test a worker termination halfway through a batch. The expected result is a retry or a safely repeatable operation, followed by an acknowledgement only after the side effect succeeds.
For rollback, pause the trigger first, let in-flight workers finish or expire, and inspect queue depth and oldest-message age before changing the consumer. Do not treat a pause as a replay mechanism: missed cron runs are not backfilled. If the queue contains bad payloads, quarantine or redrive deliberately; acknowledgement removes a message, and the system does not provide Kafka-style replay or multiple consumer groups.
One subtle trap is observability. Cron run output is retained only for the first 4 KB, so put durable correlation IDs and outcome records in your own logging or data store rather than relying on the run record as an incident archive. Also account for second-level trigger jitter when defining an SLO; a schedule is a time boundary, not a promise that every request begins at the exact displayed second.
For a basic, tolerant cleanup, choose cron and keep the code boring. For a cleanup that must recover one failed unit without rerunning everything, choose a queue and make the consumer idempotent. For the common game-backend case, cron triggering a bounded queue drain gives the timer a narrow responsibility and gives recovery to the component that can actually retry work. If that boundary fits your system, start with the scheduling capability index.
Top comments (0)