Backend Cron Alternatives for Scheduled Go Scripts with HTTP Digest Recovery
For backend cron alternatives, use a durable queue behind the timer when scheduled Go scripts must deliver a weekly customer digest over HTTP.
Short answer: let the scheduler create one digest job per active customer, let a worker own delivery and retries, and record an idempotency key before sending.
A cron expression is only a trigger. It does not tell you what happened to the work after the process exited. For a support system, that distinction matters. A digest may contain open tickets, response-time summaries, and links to conversations. Missing one is visible to a customer; sending it twice is also visible.
I treat operational recovery as the deciding axis, not the lowest advertised price.
How should backend cron alternatives run scheduled scripts with HTTP safely?
The awkward failure is not a refused connection. It is the timeout after the remote service accepted the request. The caller cannot know whether retrying will create a duplicate. A worker that retries blindly can turn a transient network event into two weekly emails or two downstream digest records.
There are several clocks in play: the schedule clock, the queue visibility timeout, the HTTP client timeout, and the recipient's own processing time. They need explicit boundaries. For example, a 10-minute worker lease cannot protect a request that is allowed to hang for 12 minutes. The lease expires, another worker starts, and both workers may send.
The job payload should be small and reconstructable. Store a customer ID, the digest period, and a deterministic key such as digest:customer-482:2026-W43; fetch the current support data when the worker runs. Do not put a mutable ticket dump into a scheduler payload and assume it will remain correct for seven days.
I start with a boring state model: queued, running, sent, and failed. sent is terminal. A failed attempt carries the next attempt time and an error category. The schedule itself is not the retry mechanism.
A small Go worker with an idempotency boundary
The following example shows the control flow rather than a vendor SDK. Store.MarkSending must be an atomic operation backed by a uniqueness constraint on the digest key. The sender must accept the same key and make its operation idempotent, or the application needs an outbox and a provider-side deduplication contract.
package digest
import (
"context"
"errors"
"fmt"
"time"
)
var ErrAlreadySent = errors.New("digest already sent")
type Store interface {
MarkSending(ctx context.Context, key string, now time.Time) error
MarkSent(ctx context.Context, key string, sentAt time.Time) error
MarkRetry(ctx context.Context, key string, next time.Time, reason string) error
}
type Sender interface {
Send(ctx context.Context, customerID, period, key string) error
}
func Deliver(ctx context.Context, store Store, sender Sender, customerID, period string, now time.Time) error {
key := fmt.Sprintf("digest:%s:%s", customerID, period)
if err := store.MarkSending(ctx, key, now); err != nil {
if errors.Is(err, ErrAlreadySent) {
return nil
}
return err
}
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := sender.Send(sendCtx, customerID, period, key); err != nil {
return store.MarkRetry(ctx, key, now.Add(15*time.Minute), err.Error())
}
return store.MarkSent(ctx, key, time.Now().UTC())
}
There is an uncomfortable boundary here: Send can succeed and MarkSent can fail. That is why a production design should expose delivery status and reconcile old sending records. A reconciliation job can ask the delivery system for the idempotency key, or safely re-submit the same key if the sender guarantees deduplication. A database transaction cannot make an arbitrary HTTP endpoint atomic.
The 30-second timeout is an example policy, not a universal setting. Measure the endpoint's normal latency, choose a limit that leaves room inside the worker lease, and test the timeout path. The important part is that a request has a deadline and that a retry has a reason. That is the trade-off.
How should the schedule and queue divide the work?
The weekly timer should do one bounded task: enumerate active customers and enqueue deterministic jobs. It should not render every digest and call every HTTP endpoint in one process. If it dies after customer 482, the next run can safely attempt to enqueue the same keys; the queue or database rejects duplicates.
A queue worker then handles backoff, concurrency, visibility renewal, and dead-letter handling. Exponential backoff avoids a synchronized retry storm. A maximum attempt count prevents one malformed customer record from consuming worker capacity forever. Permanent errors, such as an invalid recipient address, should be separated from transient errors, such as a connection reset.
Keep the schedule timestamp in UTC and derive the period from an explicit business timezone. A customer-support digest for a Monday morning local window should not depend on the machine timezone of whichever worker happens to run it. Around daylight-saving transitions, store the intended period and test both repeated and skipped local times.
For HTTP delivery, log the digest key, customer ID, period, attempt number, response class, and elapsed time. Do not log the digest body or authorization header. Alert on the age of the oldest queued job and on the count of jobs in failed or sending beyond their expected lease. A successful scheduler heartbeat proves almost nothing about delivery.
What do external automation tools change?
Visual automation products can shorten the path from a timer to an HTTP action, but the operational contract still needs to be read carefully. Zapier's paths and task history are useful for straightforward business workflows; the engineering question is how its retry, replay, retention, and idempotency behavior maps to the digest key. n8n can be self-hosted and lets a team inspect workflow execution data, but that shifts upgrades, persistence, worker capacity, and incident response into the team's ownership. GitHub Actions can run scheduled workflows and scripts, but repository workflow scheduling is a different operating model from a customer-facing delivery queue, especially when backlog control and per-customer replay matter.
Those are boundaries, not rankings. A timer plus HTTP step may be enough for a non-critical internal cleanup. A customer digest needs an answer to four questions before adoption:
- where is the durable job record,
- how is a duplicate prevented,
- how is a poisoned job isolated,
- and how can an operator replay one period for one customer?
If the answers require a spreadsheet and manual clicking, the apparent simplicity is hiding operational work.
Here is the comparison I use before choosing an implementation:
| Option | Integration | Setup effort | Fits best | Main limitation |
|---|---|---|---|---|
| Kubernetes CronJob | Container command | Medium | A scheduled batch already running in a cluster | Scheduling does not provide idempotent HTTP delivery |
| Cloud Tasks | REST or client library | Medium | Delayed HTTP work with queue-level retries | The application still owns digest state and reconciliation |
| Zapier | Visual actions and webhooks | Low | Small business workflows with modest replay needs | Execution history and retry semantics may not match a per-customer recovery contract |
| n8n | Visual workflow or HTTP node | Medium | Teams willing to operate workflow persistence | Self-hosting moves upgrades, capacity, and incident response to the team |
| GitHub Actions | YAML workflow and scripts | Low | Repository maintenance and developer automation | Repository scheduling is a poor fit for customer backlog control |
The table is a boundary map, not a leaderboard. The cheapest setup can still leave an operator without a safe replay button.
A backend cron process is not automatically better. Kubernetes CronJob documents scheduling semantics and concurrency policies, but it does not turn an application-level HTTP side effect into an exactly-once operation. A managed task service can provide delayed delivery and retry controls, while a queue with a dead-letter queue makes failed messages inspectable. The useful comparison is the failure contract each option makes visible. The limitation of a timer-only design is clear: it knows that the invocation began, but not whether the remote system accepted the request before the connection failed.
Verification, rollback, and the 03:00 check
Before production, run a matrix of deliberate failures: kill the scheduler after enqueueing half the customers; return HTTP 429 and 500 responses; delay the response beyond the client deadline; terminate a worker after the remote side accepts the request; and replay the same period twice. The expected result is boring: one durable job key, bounded retries, a visible terminal failure, and no duplicate digest.
I would also run a dry period against a fixed fixture of active customers. Compare the generated set with the expected set, then inspect a sample of rendered links. This catches a more ordinary incident: a query that silently excludes customers because “active” was interpreted as “logged in during the last seven days.”
Rollback should disable new scheduling without deleting queued jobs. Let workers drain, or pause them while an operator decides whether the new code can safely process old payloads. Keep the schema able to read the previous job version. If the sender format changes, include a payload version in the key and make the migration explicit.
The 03:00 check is a useful runbook item: inspect the oldest queued job, the last successful digest period, retry counts by error class, and the reconciliation age. If those four signals look healthy, investigate the recipient-facing system next. Do not declare success from the scheduler's “completed” log line.
The decision rule is straightforward. Use a timer alone for work that can be safely repeated and has no customer-facing consequence. Add a durable queue, idempotency key, and reconciliation path when a missed or duplicated HTTP side effect needs an operator-visible recovery process. That rule survives changes in vendors, runtimes, and deployment platforms.
References
- AWS, “Using Amazon SQS dead-letter queues”: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Google Cloud, “Cloud Tasks overview”: https://cloud.google.com/tasks/docs/dual-overview
- Kubernetes, “CronJob”: https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
- HTTP Semantics, RFC 9110: https://www.rfc-editor.org/rfc/rfc9110
- Zapier, “How to handle errors in Zaps”: https://help.zapier.com/hc/en-us/articles/8496288698253-How-to-handle-errors-in-Zaps
- n8n, “Queue mode”: https://docs.n8n.io/hosting/scaling/queue-mode/
- GitHub Docs, “Events that trigger workflows”: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
Comments
No comments yet. Start the discussion.