Durable workflows for Go · Redis and nothing else

Flows that outlive the process,
written as ordinary Go code

Close the order in thirty minutes. Email the user three days after signup. Ship a deploy halfway through a hundred-thousand-row job. These flows live longer than the process that started them. gotick lets you write them down as they read — and a restart, a crash or a rescale won't send them back to the beginning.

GitHub starsMIT licensed
order.go
// close the order 30 minutes after it is createdtick.Flow("order/close", func(ctx *gotick.Context) {gotick.Task(ctx, "create-order", createOrder)gotick.Sleep(ctx, "wait-payment", 30*time.Minute)gotick.Task(ctx, "close-order", closeOrder)})
What actually happens

The code reads straight down — but it can be interrupted, restarted, retried

gotick doesn't compile your flow into a state machine. Every scheduling round runs your function again from the top, and the steps that already finished are skipped using state in Redis. The demo below puts three things on screen at once: which line the code is on, what state each task is in, and how many times your function has run from the top.

order/closeNot started
The code you wrote
1tick.Flow("order/close", func(ctx *gotick.Context) {
2 gotick.Task(ctx, "create-order", createOrder)
 
4 // wait half an hour, holding no process meanwhile
5 gotick.Sleep(ctx, "wait-payment", 30*time.Minute)
 
7 gotick.Task(ctx, "charge", chargeCard)
8 gotick.Task(ctx, "send-receipt", sendReceipt)
9})

The function hasn't run once yet. It only executes the first time after something calls Trigger.

What actually happened0× from the top
Something called Trigger("order/close") — the flow starts here
create-orderIdle
wait-paymentIdle
chargeIdle
send-receiptIdle
Execution logclick one to jump there

Every breakpoint that writes state costs one scheduling round, and the function returning costs one more. So a flow with four tasks runs from the top at least five times.

Watch this flow execute
13 seconds. Kill the process at any point and see how it picks back up.
What makes it reliable

The hardest part to get right is asynq's job

Deliver on time, retry on failure, don't lose work when a worker dies mid-flight — that's the part of a distributed queue that is easiest to get wrong and hardest to test yourself. gotick doesn't reinvent it. It stands on asynq (13k+ stars), a job queue plenty of Go projects already run in production. asynq gets the task into some process, on time and at least once. gotick's job starts after that: carrying on from wherever the flow left off.

Delivery and retries are asynq's

Enqueueing, delayed delivery, exponential backoff, reclaiming tasks from a worker that died mid-flight — all of it already existed; none of it is new code of ours. 25 retries by default, and tasks that exhaust them are archived for 90 days.

The state layer is ours

asynq guarantees at-least-once, which means the same task can arrive twice. What makes that safe is gotick's state: every step is persisted the moment it finishes, so a duplicate delivery is skipped rather than run again.

Ownership settled by CAS

Status writes go through a Redis Lua script doing an atomic compare-and-set, with an epoch and a heartbeat deciding who owns execution — so a process that froze and came back can't overwrite work someone else already finished.

Put plainly: on reliability gotick bets on the parts asynq has already proven, and takes responsibility only for the replay layer — which is the part that has tests.

Waiting on the outside world

Triggered by signal — wake a waiting flow at any time

When something has no fixed schedule, like a user paying, an event wakes the parked flow so it can carry on with the work or close it out.

// wait for payment, but no longer than 30 minutes
paid, ok := gotick.WaitForSignal[Payment](ctx, "paid",
gotick.WithSignalTimeout(30*time.Minute))
if !ok {
gotick.Task(ctx, "close-order", closeOrder)
return
}
gotick.Task(ctx, "ship", shipOrder)
Polling works, but it's expensive

Sleep 30 seconds, query the database, sleep again. Money that arrived at minute 3 ships at minute 3:30, and you ran 60 queries to learn that nothing happened.

Two flows means a race you have to win yourself

One flow times out, another receives the payment, and they have to cancel each other. The timer already marked the order closed when the callback lands — money taken, order closed. Worst of all it fails silently: no error, just the occasional paid order that never shipped, found weeks later during reconciliation.

A signal settles it in one atomic operation

At the moment of timeout a sentinel claims the signal slot: claim it and the timeout is real, so any late payment is rejected; fail to claim it and a payment landed in that same instant, so that one counts. Both sides can never think they won, and every later replay reads the same answer.

Address a flow by your own order ID, not a callId

Pass an identifier your business already has when you trigger, then cancel and signal with that same key — no extra column on your orders table just for gotick. Trigger the same key again and the previous unfinished run is canceled automatically; only the latest one carries on.

tick.Trigger(ctx, "order/close", meta, gotick.WithKey(orderId))
tick.CancelByKey(ctx, "order/close", orderId, reason)
tick.SendSignalByKey(ctx, "order/close", orderId, "paid", payment)

Superseding doesn't delete the old run, it cancels it: the old one lands in a canceled state with a reason naming what replaced it, visible in the inspector. Ten config edits in a row where only the last preview matters, an order whose state changed again so the run built on the old state must stop — same shape.

Cancellation is part of the same machinery: a wait with no timeout really does wait forever, and one Cancel stops it. A complete runnable example lives in example/payment_race in the repo.

What it's for

Long-running flows in Go used to mean picking the less bad of two options

We've been down both roads. One of them slowly turns into a bad workflow engine that is now yours to maintain. The other asks you to run a platform before you can close an order in half an hour. gotick is going for the gap in between.

 By handdelayed queue + status columnsTemporalgotick
New infrastructureNone — the queue and DB you haveserver + datastore + UINone — the Redis you already run
Where the flow livesSpread across status columns and cleanup jobsWorkflow + Activity + WorkerOne ordinary Go function
Adding a step in the middleAlter the table, usually migrate dataChange code, then handle versioningChange code
Idempotency and retriesWritten by hand, every timeThe engine handles itThe engine handles it
Concepts to learn firstNone, but the mess compoundsA whole vocabularySix functions
Running it locallyJust run itBoot the whole stack firstJust run it
The honest spec sheet

What it's good at, and what it isn't

The right-hand column isn't a disclaimer, it's how you decide. If any single line there is a dealbreaker for you, pick something else — that costs a great deal less than migrating off it later.

Strengths
The whole API is six functions

Task, Memo, Sleep, Array, Async+Wait, WaitForSignal. You write them inside an ordinary closure and steer the flow with ordinary for and if. No activities to register, no worker or task-queue vocabulary to learn first. From the outside there are only two more: SendSignal and Cancel.

Nothing to deploy

go get and start writing. It leans on the Redis you already run, not on a new piece of infrastructure. Local development doesn't begin by booting a server.

Flows outlive processes

Restarts, deploys, crashes and rescaling won't send a flow back to the start. Every step is persisted the moment it completes, and recovery skips whatever is already done.

The inspector ships with it

It's a plain http.Handler — mount it on the mux you already have, again with nothing new to deploy. It lives in its own sub-package, so if you never import it your binary doesn't grow by a single byte.

Weaknesses
Re-execution is invisible in the source

Your flow function is run from the top over and over, and nothing in the syntax says so. A casual time.Now() or one database read can send two runs down different branches. There is no enforcement today, only a warning in the docs.

Not for high throughput or very long histories

Every scheduling round runs the entire function from the top. Dozens of steps are fine. Thousands will hurt.

No workflow versioning

A flow runs for three days, you ship a deploy that adds a step, and the old instances run straight into the new code. Temporal solves this with a patch API. gotick doesn't, yet.

Go only, and still young

There are no cross-language SDKs. It also hasn't been proven at anything like Temporal's scale — if a flow going wrong costs you money, think that through before you commit.

Against the alternatives

Whether it fits, in one table

This field isn't short of good tools. What's scarce is the one that actually fits. gotick's spot is specific: you already run a Redis, the flows you need are a handful to a few dozen steps, and you'd rather not take on another service to get them. The table also says when to pick something else — getting that choice wrong costs far more than the ten minutes it takes to read.

ToolServer to runStorageLanguagesWhen to pick it
gotickNoneRedisGoYou already run a Redis and want a few dozen lines of ordinary Go to get a time-spanning flow right
Temporalserver + datastore + UICassandra / PG / MySQLMulti-languageThe flows themselves are core assets: auditing, several languages, years of evolution, and a team willing to run the platform
DBOS TransactNonePostgres onlyGo / Python / TS / JavaPostgres is already your main database, and you accept production-grade observability being tied to a paid console
go-workflowsNoneSQLite / MySQL / PG / RedisGoYou want Temporal's strict determinism model and accept the tighter constraints it puts on how you write code
RestateYes (single binary)Built inMulti-languageYou want a lighter server than Temporal and don't mind that the server is BUSL licensed
InngestYes (cloud or self-hosted)Managed by the serverTypeScript firstTypeScript is your primary language and you want a hosted service with a console out of the box
River / asynqNonePostgres / RedisGoYou only need a reliable background job queue, not multi-step flows — don't pay for capabilities you won't use

The short version: if you need cross-language support, auditability or very long histories, pick Temporal. If Postgres is already your main database, look at DBOS. If all you need is a job queue, pick asynq or River — don't pay for capabilities you'll never use.

Visible

When it breaks, you can see which step it stopped on

When a flow spans minutes to days, logs alone won't reassemble the picture. gotick ships with an inspector: which instances are running, how long each step took, where it failed, and how long a sleeping one has left. It's a plainhttp.Handler that reads Redis directly — so it never has to reach a running worker, and there's nothing to deploy for it.

The gotick inspector showing a sleeping flow, with the time left before it wakes and a sleep progress bar
A waiting flow tells you how much is left. The countdown runs locally — it isn't polling.
The gotick inspector showing the execution log: what each run did and how long since the previous one
What each run did and how long since the last one, all laid out — latency problems usually hide in the gaps.

Where to look: pick one of three

Because it's only a handler, where the inspector runs is your call: fold it into a service you already have, give it a port of its own, or change no code at all and take a look from your terminal. None of the three needs a new service.

01Mount it on your mux

Folds into a service you already ship, and goes live with it. Least work.

mux.Handle("/_gotick/", h)
02Give it its own port

Separate from your app's port. Binding anywhere but localhost requires a password, or it refuses to start.

ui.ListenAndServe(addr, opt)
03Look from your terminal

Something's wrong in production: no code change, no deploy — point the CLI at Redis and see the scene.

gotick ui -redis ...

The inspector lives in its own sub-package. Never import it and your binary doesn't grow by a single byte — measured, not assumed.

Getting started

Three steps, nothing to deploy

01
Install
go get github.com/zbysir/gotick
02
Write
tick.Flow("order/close", ...)
03
Run
tick.StartServer(ctx)

After step three the flows are running. They sit in the same process as your business code and go out with however you already deploy. There is no step four.

Read the full documentation
Say something

What's still missing?

This project is still growing. Every line on that weaknesses list is known — which one gets fixed first depends on someone actually being blocked by it. If you read this far, wanted to use it, and were one step short, tell us which step. That beats any roadmap.

Render diagnostics