Background jobs for Go that you can actually see.
One library, one dashboard, and your choice of Postgres, MySQL or Redis — with the same API and the same UI whichever you pick.
var SendWelcome = jobsync.Declare[WelcomeArgs]("email.welcome")
SendWelcome.Enqueue(ctx, client, WelcomeArgs{Email: "new@user.com"})That's the whole enqueue path. The dashboard above is one line to mount.
Go has excellent job libraries — River on Postgres and asynq on Redis are both worth your time, and if you are settled on one of those backends you should look at them first.
jobsync is built around a storage contract rather than a database. The same API, the same dashboard and the same guarantees run on Postgres, MySQL or Redis, and you can move between them without rewriting your jobs.
| jobsync | |
|---|---|
| Storage | Postgres, MySQL, Redis, or your own driver |
| Dashboard | one http.Handler in your process, identical on every driver |
| Transactional enqueue | yes, on the engines that support it |
| Recurring jobs | cron with per-fleet locking, in the same library |
Running MySQL? The MySQL driver is first-class here — same conformance suite, same dashboard, same semantics as Postgres and Redis.
go get github.com/beesaferoot/jobsyncGo 1.22+. Pick a driver: driver/postgres, driver/mysql, driver/redis, or
driver/memory for tests.
store, _ := postgres.Open(ctx, os.Getenv("DATABASE_URL")) // connects + migrates
client, _ := jobsync.NewClient(store)
srv := jobsync.NewServer(store, jobsync.ServerConfig{Concurrency: 20})
SendWelcome.Handle(srv, func(ctx context.Context, a WelcomeArgs) error {
return mailer.Send(ctx, a.Email)
})
go srv.Run(ctx)
http.Handle("/jobs/", jobsync.Dashboard(store, jobsync.DashboardConfig{BasePath: "/jobs"}))Open runs its own migrations, and the dashboard is a handler inside the
process you already deploy.
type WelcomeArgs struct {
UserID int `json:"user_id"`
Email string `json:"email"`
}
// Declared at package level. Carries no dependencies, so anything can enqueue it.
var SendWelcome = jobsync.Declare[WelcomeArgs]("email.welcome")
// The implementation is bound at wiring time, where dependencies exist.
SendWelcome.Handle(srv, func(ctx context.Context, a WelcomeArgs) error {
return mailer.Send(ctx, a.Email) // mailer captured here
})Declare gives you a typed handle that any package can enqueue against.
Handle binds the implementation at wiring time, where your dependencies
already exist — so they arrive by closure capture, which is what Go offers in
place of a container.
A complete program
package main
import (
"context"
"log"
"net/http"
"github.com/beesaferoot/jobsync"
"github.com/beesaferoot/jobsync/driver/postgres"
)
type WelcomeArgs struct {
Email string `json:"email"`
}
var SendWelcome = jobsync.Declare[WelcomeArgs]("email.welcome")
func main() {
ctx := context.Background()
store, err := postgres.Open(ctx, "postgres://user:pass@localhost/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer store.Close()
client, err := jobsync.NewClient(store)
if err != nil {
log.Fatal(err)
}
srv := jobsync.NewServer(store, jobsync.ServerConfig{
Queues: []string{"default"},
Concurrency: 20,
})
SendWelcome.Handle(srv, func(ctx context.Context, a WelcomeArgs) error {
return sendMail(ctx, a.Email)
})
go srv.Run(ctx)
SendWelcome.Enqueue(ctx, client, WelcomeArgs{Email: "new@user.com"})
http.Handle("/jobs/", jobsync.Dashboard(store, jobsync.DashboardConfig{
BasePath: "/jobs",
}))
log.Fatal(http.ListenAndServe(":8080", nil))
}Mount it on any http.ServeMux and you get the whole thing. It ships as a single
embedded HTML file, so it renders offline and stays out of your build pipeline.
See what is failing, not just how much. Dead and retrying jobs are grouped by cause, with IDs and numbers masked so "order 41 not found" and "order 97 not found" count as one problem. Each group has its error, when it started, and a button to requeue the lot once the cause is fixed.
Find the job that broke. Filter by state or queue from the sidebar; the failure message is in the table, so you don't have to open anything to see what went wrong.
Then open it. The job as the call that created it, the last error, and a
state history with +Nms deltas — how long it waited, how long it ran, how long
until the retry.
Recurring jobs, with the schedule in words, next and last run, and a Trigger button for when somebody asks whether the nightly report still works.
Also: queue pausing for when a downstream API is down and you'd rather not burn ten thousand retries against it, and a live server list so you can see your fleet during a deploy.
Auth defaults to LoopbackOnly: nothing to configure on a laptop, and not
world-readable if it ships. Behind a reverse proxy you must set it, because
LoopbackOnly refuses any request carrying X-Forwarded-For — a proxy is
usually itself on loopback, so a naive loopback check is wide open.
jobsync.Dashboard(store, jobsync.DashboardConfig{
BasePath: "/jobs",
Auth: jobsync.BasicAuth(user, pass),
// or: jobsync.AuthorizeFunc(yourSessionCheck, loginRedirect)
})SendWelcome.Enqueue(ctx, client, args) // now
SendWelcome.Schedule(ctx, client, args, time.Now().Add(time.Hour)) // later
SendWelcome.Cron(ctx, client, "nightly", "0 2 * * *", args) // recurring
SendWelcome.EnqueueTx(ctx, client, tx, args) // in your transactionSendWelcome.Enqueue(ctx, client, args,
jobsync.Queue("mail"),
jobsync.Priority(0), // lower runs first
jobsync.MaxAttempts(5),
jobsync.In(5*time.Minute),
jobsync.Tags("billing"),
jobsync.Unique("report:2026-10-05"), // drop if one is already pending
)EnqueueTx makes a job and the rows that justify it commit together, so a
job can never reference a row that rolled back. SQL drivers only; it errors on
Redis rather than silently writing outside your transaction.
Cron runs once per fleet, not once per server — one server wins a lock per
tick. Recurring jobs need an explicit timezone; time.Local is rejected,
because a schedule stores its zone by name and "Local" means something
different on every machine.
| Postgres | MySQL | Redis | memory | |
|---|---|---|---|---|
| Jobs, retries, scheduling | ✅ | ✅ | ✅ | ✅ |
| Recurring jobs | ✅ | ✅ | ✅ | ✅ |
| Dashboard | ✅ | ✅ | ✅ | ✅ |
| Queue pausing | ✅ | ✅ | ✅ | ✅ |
| Transactional enqueue | ✅ | ✅ | — | — |
| Free-text search | ✅ | ✅ | — | ✅ |
MySQL requires 8.0+ / MariaDB 10.6+ for SELECT ... FOR UPDATE SKIP LOCKED.
Open refuses older servers rather than failing on every poll.
Redis declines free-text search; the dashboard reads that capability and hides the search box rather than returning unfiltered results. Priority is clamped to 0–9.
memory is not for production. It is the control for the conformance suite, and
it is useful in application tests where a real queue is more infrastructure than
the test needed.
At-least-once, lease-based. A job claimed by a server that dies returns to its queue once the lease expires. Handlers should be idempotent; there is no exactly-once and the docs will not pretend otherwise.
Due-ness uses the storage's clock, never the calling process's — it's the only clock every server shares.
A permanent failure skips its remaining attempts. Return an error wrapping
jobsync.ErrPermanent and the job goes straight to dead. A payload that will not
decode is treated this way automatically — it would not decode on the tenth
attempt either, and the retries bury real failures under noise.
A duplicate job ID is ignored, not overwritten. This is what makes the scheduler idempotent: a server crashing between enqueuing a tick and recording it refires the same tick as a no-op.
Retention. SQL drivers keep terminal jobs for Retention (7 days default) —
run store.Janitor(ctx, time.Hour) to enforce it. Redis uses a TTL instead.
Storage is five methods: Enqueue, Fetch, Extend, Finish, Reclaim.
Everything else — Locker, Schedules, Monitor, QueueControl,
TxEnqueuer, Heartbeat — is an optional interface found by type assertion. A
driver implementing only Storage runs jobs correctly; it just lights up fewer
dashboard panels.
The contract speaks jobs, queues, leases and schedules — never sets, hashes or counters. That's the deliberate break from Hangfire, whose storage API exposes Redis data structures and so forces every SQL driver into emulation tables.
Prove your driver with the suite the built-in ones are held to:
func TestConformance(t *testing.T) { storagetest.Run(t, newStore) }
func TestDashboardAPI(t *testing.T) { storagetest.RunAPI(t, newStore) }It pins the things that are invisible until production: that Fetch is atomic
across processes, that a late Finish from a server whose lease expired is
ignored rather than applied, that a terminal job releases its unique key, and
that timestamps survive a non-UTC session.
docker compose up -d # postgres, redis, mysql
go run ./examples/dashboard # http://127.0.0.1:8787/jobs/STORAGE=postgres|mysql|redis switches the driver — the UI is identical, minus
whatever that driver declines to support.
go test -race -count=5 ./... # -count catches the clock- and lease-boundary racesPre-1.0. The Storage contract is settled and covered by the conformance suite,
but it may still gain optional interfaces. Not yet implemented: continuations
and batches, and batched fetch (Fetch claims one job per round trip).
See DESIGN.md for the architecture and the reasoning behind the storage
contract.
MIT — see LICENSE.




