Nov 11, 20266 min read

How to Add Cron Jobs to an Echo App

Schedule recurring work in a Labstack Echo API, such as releasing expired booking holds and purging old rows, so each run happens once however many replicas you have, with Encore, gocron or an external scheduler.

Illustration for How to Add Cron Jobs to an Echo App

A booking API built on Echo holds a slot for ten minutes while the customer pays, and something has to release the holds nobody paid for. Echo has no scheduler, so that job usually becomes a goroutine with a ticker in main, which runs once per replica after the first scale-out and stops silently whenever a deploy restarts the process at the wrong moment.

This guide adds two jobs to an Echo booking API: one that releases expired holds every five minutes and one that purges old bookings at 02:30 UTC. It shows the Encore version first, then gocron inside the process, then a scheduler outside it.

Cron options for an Echo app at a glance

Encoregocron in-processEventBridge Scheduler or Kubernetes CronJob
Schedule declared inGo code (cron.NewJob)Go code in mainTerraform, YAML or the console
Runs per scheduleOnce, however many instances runOnce per replica unless you add a distributed lockOnce
Job codeA private endpoint in the Echo serviceA function in the same binaryA separate task, container or HTTP target
Extra infrastructureNoneA Redis or Postgres lock for multi-replicaA schedule, a target and an IAM role or ServiceAccount
Runs locallyNocall the endpoint from the dashboardYesNo
Run historyEncore dashboard with a trace per runYour logsThe scheduler's console

How do you add cron jobs to Echo with Encore?

Encore

Encore works alongside Echo and provisions what the Go code declares in your own AWS or GCP account. The Echo router keeps serving the public API from a raw fallback route, and each job is a private endpoint in the same package with a cron.NewJob declaration pointing at it.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create bookings-api --example=hello-world$ cd bookings-api && go get github.com/labstack/echo/v4@v4.16.3$ mkdir -p bookings/migrations

A partial unique index allows only one held or confirmed booking per slot, which is what lets the release job and the hold endpoint run at the same moment without double-booking:

-- bookings/migrations/1_create_bookings.up.sql CREATE TABLE bookings ( id BIGSERIAL PRIMARY KEY, slot_id BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'held', hold_expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX one_active_booking_per_slot ON bookings (slot_id) WHERE status IN ('held', 'confirmed');
// bookings/bookings.go package bookings import ( "context" "net/http" "time" "encore.dev/cron" "encore.dev/rlog" "encore.dev/storage/sqldb" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) var db = sqldb.NewDatabase("bookings", sqldb.DatabaseConfig{ Migrations: "./migrations", }) //encore:service type Service struct { e *echo.Echo } func initService() (*Service, error) { e := echo.New() e.Use(middleware.Recover()) e.POST("/slots/:slot/hold", holdSlot) return &Service{e: e}, nil } //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.e.ServeHTTP(w, req) } func holdSlot(c echo.Context) error { var id int64 err := db.QueryRow(c.Request().Context(), ` INSERT INTO bookings (slot_id, hold_expires_at) VALUES ($1, now() + interval '10 minutes') ON CONFLICT DO NOTHING RETURNING id `, c.Param("slot")).Scan(&id) if err == sqldb.ErrNoRows { return echo.NewHTTPError(http.StatusConflict, "slot is taken") } else if err != nil { return err } return c.JSON(http.StatusCreated, map[string]int64{"booking_id": id}) } // Release holds that were never paid for, every 5 minutes. var _ = cron.NewJob("release-expired-holds", cron.JobConfig{ Title: "Release expired booking holds", Every: 5 * cron.Minute, Endpoint: ReleaseExpiredHolds, }) // ReleaseExpiredHolds is safe to run twice: a released hold no longer matches. // //encore:api private func ReleaseExpiredHolds(ctx context.Context) error { res, err := db.Exec(ctx, ` UPDATE bookings SET status = 'expired', updated_at = now() WHERE status = 'held' AND hold_expires_at < now() `) if err != nil { return err } rlog.Info("released holds", "count", res.RowsAffected()) return nil } // Delete old expired and cancelled bookings at 02:30 UTC every day. var _ = cron.NewJob("purge-old-bookings", cron.JobConfig{ Title: "Purge bookings older than 90 days", Schedule: "30 2 * * *", Endpoint: PurgeOldBookings, }) //encore:api private func PurgeOldBookings(ctx context.Context) error { cutoff := time.Now().AddDate(0, 0, -90) _, err := db.Exec(ctx, ` DELETE FROM bookings WHERE status IN ('expired', 'cancelled') AND updated_at < $1 `, cutoff) return err }

The cron jobs docs require job endpoints to take no request parameters, an Every interval that divides 24 hours evenly, and cron expressions in UTC. Both endpoints are private, so only the scheduler and other services in the app can call them, and both are written as set-based SQL statements that give the same result when repeated, since a job can be called more than once under some network conditions.

Cron jobs do not run under encore run or in preview environments, so the local loop is creating a hold with curl -X POST localhost:4000/slots/7/hold, calling ReleaseExpiredHolds from the local development dashboard once the hold has passed its expiry, and reading the trace of the UPDATE. In deployed environments the schedule is managed by the Encore platform, which sends a signed request to the endpoint once per run and needs no scheduler, task or role in your cloud account, as the infrastructure docs note. A coding agent that adds a third job, such as a reminder before a hold expires, adds the endpoint and its cron.NewJob in the same file, and the schedule appears in the dashboard after the next deploy.

Best fit: Echo APIs running on more than one instance that want each job declared next to the code it runs, called once per schedule in their own AWS or GCP account, and traced like any other request.

In-process scheduling with gocron

gocron v2 runs jobs in the same binary as Echo, which keeps everything in one deploy and runs locally with go run:

s, err := gocron.NewScheduler( gocron.WithLocation(time.UTC), gocron.WithDistributedLocker(locker), // e.g. a Redis-backed locker ) if err != nil { log.Fatal(err) } _, err = s.NewJob( gocron.DurationJob(5*time.Minute), gocron.NewTask(releaseExpiredHolds, context.Background()), gocron.WithSingletonMode(gocron.LimitModeReschedule), ) if err != nil { log.Fatal(err) } s.Start() defer s.Shutdown() e.Logger.Fatal(e.Start(":8080"))

Without the locker, every replica runs every job, so the lock is not optional once the service scales, and it brings Redis or a Postgres advisory lock into the picture along with a decision about what happens when the lock holder dies mid-run. Deploys stop the scheduler with the process, so a run that was in progress needs to be safe to restart, and the job's logs are mixed into the API's request logs unless you separate them.

Best fit: single-instance Echo services, or teams that already run Redis and want the scheduler in the same binary.

A scheduler outside the process

On AWS, EventBridge Scheduler can start an ECS task from the same image with a different command, and on Kubernetes a CronJob does the same with concurrencyPolicy: Forbid to stop overlapping runs. Either way each run happens once, at the cost of a second entrypoint in the Go binary, a schedule and a target defined in Terraform or YAML, an IAM role that lets the scheduler run the task, and a run history that lives in a different console from the API's traces.

Best fit: teams that already manage ECS tasks or Kubernetes manifests and want jobs isolated from the API's containers.

How to choose

Pick Encore when the Echo app runs on several instances and you want jobs declared in Go, called once per schedule in your own AWS or GCP account and visible next to the API in one dashboard. Pick gocron for a single instance or when a distributed lock is already in place. Pick EventBridge Scheduler or a Kubernetes CronJob when jobs should run as separate tasks managed with the rest of your infrastructure code.

Frequently asked questions

Does Echo have a built-in scheduler?

Echo has no scheduler of its own, so recurring jobs come from an in-process library such as gocron or robfig/cron, from an external scheduler that calls the app or starts a task, or from a platform that triggers an endpoint on a schedule.

How do I add a cron job to an Echo app with Encore?

Write the job as a private Encore endpoint with the signature func(ctx context.Context) error and declare var _ = cron.NewJob("id", cron.JobConfig{Title: "...", Every: 5 * cron.Minute, Endpoint: MyJob}), or use Schedule with a cron expression in UTC. The Echo routes stay in a raw fallback endpoint, and the Encore platform calls the job once per schedule in deployed environments.

Why does my scheduled job run twice after scaling my Echo service?

An in-process scheduler starts in every replica, so two replicas run every job twice. Either elect one leader with a distributed lock in Redis or Postgres, or move the schedule outside the process so a single trigger calls the job once.

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders