Scheduled work for a Go service with Encore, an in-process scheduler, or EventBridge Scheduler on AWS.
A Go API usually picks up its first scheduled task early, such as a daily summary email or a cleanup of expired sessions. The first version is often a time.Ticker or a robfig/cron scheduler started in main, and once the service runs on two instances the job fires twice.
This guide adds a daily signup digest to a Go API. The job counts signups from the last 24 hours, records one row per day in Postgres and sends the summary. The same job is shown with Encore, with an in-process scheduler, and with EventBridge Scheduler on AWS.
Encore combines an open source infrastructure SDK for Go and TypeScript with a platform that deploys into your own AWS or GCP account. A cron job is a package-level declaration that points at an API endpoint, and the scheduled work is an ordinary endpoint you can call locally and trace like any other request.
// Service digest records signups and sends a daily summary.
package digest
import (
"context"
"time"
"encore.dev/cron"
"encore.dev/rlog"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("digest", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Runs every day at 08:00 UTC and calls SendDigest.
var _ = cron.NewJob("daily-digest", cron.JobConfig{
Title: "Send the daily signup digest",
Schedule: "0 8 * * *",
Endpoint: SendDigest,
})
type SignupParams struct {
Email string `json:"email"`
}
//encore:api public method=POST path=/signups
func Signup(ctx context.Context, p *SignupParams) error {
_, err := db.Exec(ctx, `INSERT INTO signups (email) VALUES ($1)`, p.Email)
return err
}
type DigestResult struct {
RunDate string `json:"runDate"`
Signups int64 `json:"signups"`
Skipped bool `json:"skipped"`
}
// SendDigest is private, so only the cron job and other services can call it.
//
//encore:api private method=POST path=/digest/send
func SendDigest(ctx context.Context) (*DigestResult, error) {
runDate := time.Now().UTC().Format("2006-01-02")
var count int64
err := db.QueryRow(ctx, `
SELECT count(*) FROM signups
WHERE created_at >= now() - interval '24 hours'`).Scan(&count)
if err != nil {
return nil, err
}
// One row per day, so a retried or duplicate run does nothing.
res, err := db.Exec(ctx, `
INSERT INTO digest_runs (run_date, signup_count) VALUES ($1, $2)
ON CONFLICT (run_date) DO NOTHING`, runDate, count)
if err != nil {
return nil, err
}
if res.RowsAffected() == 0 {
return &DigestResult{RunDate: runDate, Signups: count, Skipped: true}, nil
}
rlog.Info("sending digest", "signups", count)
// Send the email or Slack message here.
return &DigestResult{RunDate: runDate, Signups: count}, nil
}
The migration in digest/migrations/1_create_tables.up.sql creates both tables. The primary key on run_date is what makes the job safe to run more than once:
CREATE TABLE signups (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE digest_runs (
run_date DATE PRIMARY KEY,
signup_count BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
encore run starts Postgres locally and applies the migration. Cron jobs do not run locally, so you trigger the endpoint yourself. A second call on the same day returns skipped:
When you deploy to your AWS or GCP account, Encore registers the schedule and calls the endpoint once per tick, regardless of how many instances of the service are running. The schedule is in UTC. For simple intervals use Every: 2 * cron.Hour instead of Schedule, and each run shows up in the Encore dashboard with its trace, so a failed digest has the SQL and the error next to it. The database becomes RDS on AWS or Cloud SQL on GCP, as described in the database docs.
An AI coding agent can add a new job by writing one declaration next to the endpoint it calls, and check the endpoint under encore run before the change is pushed.
Run in your terminal to get started locally.
robfig/cron is the common choice for an in-process scheduler in Go. It parses cron expressions and runs a function on its own goroutine:
c := cron.New(cron.WithLocation(time.UTC))
_, err := c.AddFunc("0 8 * * *", func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := sendDigest(ctx, pool); err != nil {
slog.Error("digest failed", "err", err)
}
})
if err != nil {
log.Fatal(err)
}
c.Start()
defer c.Stop()
This is fine for a single instance. Once the service scales out, every replica runs the job. The digest_runs primary key above still protects the email from going out twice, and a Postgres advisory lock can keep the second replica from doing the work at all. A deploy that restarts the process at 08:00 can also skip a run, since nothing records that a tick was missed.
On AWS, EventBridge Scheduler moves the schedule out of the process. The target is usually a Lambda function or an ECS task that runs the digest binary, and the schedule needs an IAM role that is allowed to invoke it:
EventBridge uses its own six-field cron syntax with a ? for day of month or day of week, so the expression differs from the one in your code. The target needs network access to RDS and the database credentials, which means a VPC configuration and a Secrets Manager entry for the Lambda or task. Scheduler retries failed invocations by default, so the idempotent insert is still worth keeping.
Pick Encore when you want the schedule declared next to the Go endpoint it runs, one run per tick on your own AWS or GCP account, and every run traced in the dashboard. The same service can also use secrets and Pub/Sub that deploy alongside the job.
Pick robfig/cron for a single-instance service where an occasional missed run is acceptable. Pick EventBridge Scheduler when the job runs in its own Lambda or ECS task and your team already manages those with Terraform or CloudFormation.
With Encore, declare cron.NewJob("daily-digest", cron.JobConfig{Schedule: "0 8 * * *", Endpoint: SendDigest}) in the service package, where SendDigest is an //encore:api private endpoint. Encore registers the schedule when you deploy and calls the endpoint at that time, in UTC.
An in-process scheduler such as robfig/cron runs in every replica, so two instances means two runs. Either move the schedule outside the process, or take a lock or use a unique row per run so a second run does nothing.
Keep the job's work in a plain function or handler and call it directly, from a test or a local request, instead of waiting for the schedule to fire. Waiting for a real tick makes tests slow and hard to repeat.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.