Migrating from asynq
asynq is in maintenance mode, and a lot of
teams moving off it land on chronos-go because the shape of the API is
familiar: a client that enqueues, a mux that routes by task type, a server
that runs handlers. The differences are what you gain by moving — a
type-safe payload instead of []byte, a scheduler that's safe to run on
every instance instead of one you have to babysit, and a unique lock that
survives long-running work instead of just a TTL.
API mapping
| asynq | chronos-go |
|---|---|
asynq.Task — []byte payload, decoded by hand |
Task[T] — generic, t.Args arrives already decoded |
asynq.ServeMux + asynq.HandlerFunc |
chronos.Mux (chronos.NewMux()) + chronos.AddHandler[T] |
asynq.Client.Enqueue |
chronos.Enqueue[T](ctx, client, args, opts...) |
App must ensure only one asynq.Scheduler runs |
chronos.Scheduler — built-in leader election, safe on every instance |
| Unique lock: TTL only, can expire mid-processing | Unique lock: heartbeat-renewed for as long as the task is being worked |
Recipe: before (asynq) and after (chronos-go)
Same task — send an email — on both sides. asynq first:
package main
import (
"context"
"encoding/json"
"log"
"github.com/hibiken/asynq"
)
type EmailPayload struct {
UserID string `json:"user_id"`
Body string `json:"body"`
}
const TypeEmailSend = "email:send"
func NewEmailSendTask(payload EmailPayload) (*asynq.Task, error) {
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return asynq.NewTask(TypeEmailSend, b), nil
}
func handleEmailSendTask(ctx context.Context, t *asynq.Task) error {
var p EmailPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return err
}
log.Printf("sending to %s: %s", p.UserID, p.Body)
return nil
}
func main() {
redisOpt := asynq.RedisClientOpt{Addr: "127.0.0.1:6379"}
// --- worker side ---
mux := asynq.NewServeMux()
mux.HandleFunc(TypeEmailSend, handleEmailSendTask)
srv := asynq.NewServer(redisOpt, asynq.Config{
Concurrency: 10,
Queues: map[string]int{"default": 1},
})
go func() {
if err := srv.Run(mux); err != nil {
log.Fatal(err)
}
}()
defer srv.Shutdown()
// --- producer side ---
client := asynq.NewClient(redisOpt)
defer client.Close()
task, err := NewEmailSendTask(EmailPayload{UserID: "u1", Body: "hi"})
if err != nil {
log.Fatal(err)
}
if _, err := client.Enqueue(task); err != nil {
log.Fatal(err)
}
select {}
}
And chronos-go:
package main
import (
"context"
"log"
"github.com/redis/go-redis/v9"
"github.com/kenshin579/chronos-go"
)
// A task type: any struct with a stable Kind() (value receiver).
type EmailArgs struct {
UserID string `json:"user_id"`
Body string `json:"body"`
}
func (EmailArgs) Kind() string { return "email:send" }
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
ctx := context.Background()
// --- worker side ---
mux := chronos.NewMux()
chronos.AddHandler(mux, func(ctx context.Context, t *chronos.Task[EmailArgs]) error {
// t.Args is strongly typed — no casting, no json.Unmarshal.
log.Printf("sending to %s: %s", t.Args.UserID, t.Args.Body)
return nil
})
srv := chronos.NewServer(rdb, chronos.ServerConfig{
Queues: map[string]int{"default": 1},
Concurrency: 10,
})
if err := srv.Start(ctx, mux); err != nil {
log.Fatal(err)
}
defer srv.Shutdown(context.Background())
// --- producer side ---
client := chronos.NewClient(rdb)
defer client.Close()
if _, err := chronos.Enqueue(ctx, client, EmailArgs{UserID: "u1", Body: "hi"}); err != nil {
log.Fatal(err)
}
select {} // keep the worker running
}
Notice what disappears: no TypeEmailSend string constant to keep in sync
with a HandleFunc registration (Kind() on the type is the single source
of truth), no json.Marshal/json.Unmarshal pair to keep in sync with each
other, no []byte in sight. t.Args in the handler is already an
EmailArgs.
Scheduler: one instance vs. every instance
With asynq, a periodic job means running an asynq.Scheduler somewhere and
making sure exactly one instance of your service runs it — two copies means
duplicate enqueues, so teams end up designating a leader pod, or running
schedulers as a separate deployment, by hand. With chronos.Scheduler
(chronos.RegisterInterval / chronos.RegisterCron, see
Scheduling), you register the same schedule on every
instance and only one of them actually fires at a time — the built-in leader
election and deterministic dedup key handle the coordination that asynq
leaves to you (see How it works for the mechanism).
Gotcha: payload serialization and lock renewal work differently
Don't reach for json.Marshal/json.Unmarshal out of habit — chronos-go's
generic Task[T] handles encoding on enqueue and decoding before your
handler runs, so t.Args is already the typed value; adding manual
(de)serialization on top is redundant and, if it disagrees with what the
generic path does, a source of subtle bugs.
The other difference is the unique lock. asynq's lock is TTL-only: pick a TTL
too short for how long the job actually takes, and the lock can expire while
the task is still processing, letting a duplicate slip in. chronos-go's
WithUnique lock (see Enqueue options) is
initially just a TTL too, but once a worker picks the task up, the server's
heartbeat renews it for as long as processing takes — see
Retries & reliability for the heartbeat
mechanism. Size the TTL for the wait, not the run.
Where to go from here
That's the whole surface this guide covers. Go back to Getting started for the full walkthrough, or head to the source and issue tracker on GitHub: github.com/kenshin579/chronos-go.