Workflows
Build durable background workflows with typed payloads, checkpointed steps, retries, sleeps, signals, and scale-to-zero execution.
Tako workflows run background work next to your app without a separate queue service. A workflow can retry after failures, remember completed steps, sleep without holding a worker open, wait for an external signal, run on a cron schedule, and let its worker process scale to zero when idle.
Code tabs cover JavaScript/TypeScript and Go where their workflow APIs correspond. JavaScript uses tako.sh; the Go worker SDK is the separate github.com/tako-sh/tako/sdk/go module, not the root Go HTTP SDK. The Rust SDK does not currently provide workflow authoring or worker APIs. Shared runtime semantics apply to both supported worker SDKs; option tables below describe the JavaScript API unless stated otherwise.
Vocabulary
| Term | Meaning |
|---|---|
| Workflow | A named handler discovered from a JS module or registered by a Go worker. |
| Run | One execution of a workflow with its own payload, run ID, attempts, and status. |
| Step | A named, checkpointed operation inside a run, created with ctx.run. |
| Worker | A separate process that claims and executes workflow runs. |
Run statuses are pending, running, succeeded, cancelled, and dead. A run may return to pending while waiting for a retry, sleep, or signal.
Define A Workflow
For JavaScript, create a module under <app_root>/workflows/ and default-export defineWorkflow. For Go, register the handler in the worker process before RunWorker:
// src/workflows/fulfill-order.ts
import { defineWorkflow } from "tako.sh";
type FulfillOrderPayload = {
orderId: string;
};
export default defineWorkflow<FulfillOrderPayload>("fulfill-order", {
retries: 4,
handler: async (payload, ctx) => {
const order = await ctx.run("load-order", () => db.orders.find(payload.orderId));
await ctx.run("charge", () =>
payments.charge({
orderId: order.id,
amount: order.total,
idempotencyKey: `${ctx.runId}:charge`,
}),
);
await ctx.run("send-receipt", () => mailer.send(order.email));
},
});// cmd/worker/main.go
package main
import (
"context"
"encoding/json"
"log"
workflow "github.com/tako-sh/tako/sdk/go"
)
func main() {
workflow.RegisterWorkflow("fulfill-order", func(ctx *workflow.WorkflowContext, raw json.RawMessage) error {
var payload struct { OrderID string `json:"orderId"` }
if err := json.Unmarshal(raw, &payload); err != nil {
return ctx.Fail(err)
}
return ctx.Step.Run("fulfill", nil, func() (any, error) {
// Your helper must use the stable key to deduplicate external effects.
return nil, fulfillOrder(payload.OrderID, ctx.RunID+":fulfill")
})
}, workflow.WithMaxAttempts(5))
if err := workflow.RunWorker(context.Background()); err != nil {
log.Fatal(err)
}
}The Go fulfillOrder helper is app-owned. Install the Go worker module with go get github.com/tako-sh/tako/sdk/go, build a worker binary, and configure its command in [workflows].run when needed. WithMaxAttempts(5) counts all attempts; JavaScript retries: 4 counts retries after the first attempt.
The payload type flows into .enqueue(payload), so callers get normal TypeScript checking without a generated workflow registry. The workflow name must be unique within the app. Using the filename stem as the name keeps discovery and logs easy to follow.
Useful definition options include:
| Option | Meaning |
|---|---|
retries |
Retries after the first run attempt. The default is 2, for three total attempts. |
backoff |
Run-level retry timing with optional base and max values in milliseconds. |
worker |
JavaScript worker group; omitted means the default lane. |
schedule |
A five-field cron expression such as "0 9 * * 1-5". |
local |
Keep the workflow on per-server local storage in a multi-server environment. |
Enqueue A Run
Import the workflow handle from server-side code and enqueue a JSON-serializable payload:
import fulfillOrder from "./workflows/fulfill-order";
const runId = await fulfillOrder.enqueue({ orderId: "ord_123" });import (
"context"
workflow "github.com/tako-sh/tako/sdk/go"
)
func enqueueOrder(ctx context.Context, orderID string) (string, error) {
result, err := workflow.Enqueue(ctx, "fulfill-order",
map[string]string{"orderId": orderID}, workflow.EnqueueOpts{})
if err != nil { return "", err }
return result.ID, nil
}You can enqueue from a request handler, another workflow, or a script running with Tako’s server runtime. Go producers also need the Tako internal socket, and container HTTP processes do not receive it in v0; see Container Workflows. Browser code cannot enqueue directly and throws TakoError("TAKO_UNAVAILABLE") if it reaches .enqueue().
Set options for a delayed run with a stable ID and seven total attempts:
await fulfillOrder.enqueue(
{ orderId: "ord_123" },
{
runAt: new Date(Date.now() + 60_000),
retries: 6,
id: "fulfill:ord_123",
},
);import (
"context"
"time"
workflow "github.com/tako-sh/tako/sdk/go"
)
func enqueueDelayedOrder(ctx context.Context) (*workflow.EnqueueResult, error) {
runAt := time.Now().Add(time.Minute)
id := "fulfill:ord_123"
maxAttempts := uint32(7)
return workflow.Enqueue(ctx, "fulfill-order", map[string]string{"orderId": "ord_123"},
workflow.EnqueueOpts{RunAt: &runAt, ID: &id, MaxAttempts: &maxAttempts})
}| Option | Meaning |
|---|---|
runAt |
Do not make the run eligible before this time. The default is now. |
retries |
Override the workflow’s retry count for this run. |
id |
Use this as the run ID. A retained run with the same ID, workflow, and payload is reused in any status. |
.enqueue() returns the run ID, including the exact id you supply. Omit id to generate one and create a new run each time. IDs must contain 1–255 bytes and no NUL characters.
Go’s EnqueueOpts uses ID, RunAt, and MaxAttempts pointers. MaxAttempts counts the first attempt as well as retries. Enqueue returns an EnqueueResult with ID and Deduplicated.
IDs are unique within the app and environment, across workflow names. Repeating an ID with a different workflow or payload produces a conflict. Concurrent enqueues with the same ID create one run. Existing runs keep their original schedule, retry budget, status, and completed steps; enqueue does not restart a succeeded, cancelled, or dead run.
Deleting a finished run’s history also frees its ID. A later enqueue with that ID creates a new run with no saved steps. Deduplication does not prevent a step’s external side effect from repeating after a crash; pass a stable idempotency key to the external service as well.
Durable Steps With ctx.run
ctx.run(name, fn) saves the function’s result. When the workflow is claimed again, a completed step with the same name returns its saved result instead of calling fn again. Keep step names stable for the lifetime of a workflow run, and keep returned values JSON serializable.
The durability boundary is the saved result, not the side effect itself. If a worker stops after fn completes but before Tako saves the result, that step runs again on the next claim. Step bodies therefore have an at-least-once execution contract. Use provider idempotency keys, upserts, conditional writes, or another domain-specific guard for side effects.
Steps can retry independently before an error reaches the run-level retry policy:
await ctx.run("call-provider", callProvider, {
retries: 2,
backoff: { base: 500, max: 10_000 },
});import (
"time"
workflow "github.com/tako-sh/tako/sdk/go"
)
func callProviderStep(ctx *workflow.WorkflowContext, callProvider func() (any, error)) error {
opts := workflow.StepRunOpts{Retries: 2}
opts.Backoff.Base = 500 * time.Millisecond
opts.Backoff.Max = 10 * time.Second
return ctx.Step.Run("call-provider", nil, callProvider, opts)
}Step retries default to 0. Set JavaScript’s retry: false or Go’s StepRunOpts{NoRetry: true} when an error from that step is permanent and should move the run directly to dead. Go’s out argument (nil above) can be a pointer to receive the checkpointed result.
The workflow context also provides runId, workflowName, attempt, and a workflow-scoped logger. Each step callback receives the same run metadata plus stepName and a step-scoped logger.
Durable Sleep
Use a stable name and a duration in milliseconds:
await ctx.sleep("wait-before-reminder", 24 * 60 * 60 * 1000);// Inside a registered Go workflow; import "time".
if err := ctx.Step.Sleep("wait-before-reminder", 24*time.Hour); err != nil {
return err
}Sleeps shorter than 30 seconds run inline. Longer sleeps park the run until its wake time so the worker can process other work or exit. Sleeping does not consume the run’s retry budget, and the wake time survives worker and server restarts.
Wait For A Signal
ctx.waitFor (JavaScript) or ctx.Step.WaitFor (Go) parks a run until matching server-side code sends a signal. With a timeout, a missing signal becomes null in JavaScript or a nil pointer result in Go:
const decision = await ctx.waitFor<{ approved: boolean; by: string }>(
`approval:order-${payload.orderId}`,
{ timeout: 7 * 24 * 60 * 60 * 1000 },
);
if (decision === null) {
ctx.bail("approval timed out");
}import (
"time"
workflow "github.com/tako-sh/tako/sdk/go"
)
type ApprovalDecision struct {
Approved bool `json:"approved"`
By string `json:"by"`
}
func waitForApproval(ctx *workflow.WorkflowContext, orderID string) error {
var decision *ApprovalDecision
if err := ctx.Step.WaitFor("approval:order-"+orderID, &decision, 7*24*time.Hour); err != nil {
return err
}
if decision == nil { return ctx.Bail("approval timed out") }
if !decision.Approved { return ctx.Bail("approval rejected") }
return nil
}Call the Go helper from the registered handler and return its error so the worker can park or cancel the run. Its event name is also the durable step name; keep it stable across retries.
Send the signal from a request handler, webhook, script, or another workflow:
import { signal } from "tako.sh";
const woken = await signal(`approval:order-${orderId}`, {
approved: true,
by: userId,
});import (
"context"
workflow "github.com/tako-sh/tako/sdk/go"
)
func approveOrder(ctx context.Context, orderID, userID string) (uint64, error) {
return workflow.Signal(ctx, "approval:order-"+orderID,
map[string]any{"approved": true, "by": userID})
}signal wakes every run waiting on that event name and returns the number of runs woken. Like enqueue, it is server-only and throws TakoError("TAKO_UNAVAILABLE") without an installed Tako runtime. Waiting and resuming do not consume retry attempts. Completed steps before the wait remain checkpointed.
Finish, Bail, Fail, Or Retry
| Handler outcome | Run result |
|---|---|
| Return normally | succeeded. |
| Throw a regular error | Retry while attempts remain, then become dead. |
ctx.bail(reason?) |
cancelled immediately, with no retry. |
ctx.fail(error) |
dead immediately, with no retry. |
Long ctx.sleep or ctx.waitFor |
Return to pending without spending the retry budget. |
Run retries use exponential backoff with 20% jitter. The default delay starts at one second and is capped at one hour. retries: 4 means four retries after the first attempt, for five total attempts.
Go handlers return nil for success, an ordinary error for retry, ctx.Bail(reason) for cancellation, or ctx.Fail(err) for permanent failure. Return errors from ctx.Step operations so the worker can park or retry the run.
Use ctx.bail when the work is no longer needed, such as a rejected or expired approval. Use ctx.fail for a permanent error that another attempt cannot fix. To finish successfully before the bottom of a handler, return normally.
Workflow and step lifecycle logs appear in the normal Tako log stream. Use ctx.logger and the logger passed to ctx.run for messages that carry the run and step context.
Scale To Zero
Tako keeps no workflow worker running while the queue has no runnable work. Enqueue, cron, a signal, a due retry or sleep, and reclaimed work can wake a worker. The worker exits after its idle window and the next runnable run starts a fresh process.
Production starts one scale-to-zero lane per JavaScript worker group; Go and container workers use a single lane. Workflows with worker: "email" run in a process that only claims that group. The config parser also accepts worker counts, concurrency, and per-server overrides; those values are not applied yet.
JavaScript and Go workers honor the runtime-provided lane concurrency (currently 500). Go workers stop claiming on cancellation and wait for active handlers to finish, continuing their lease heartbeats while draining. Concurrency is a worker setting; defineWorkflow does not expose per-workflow concurrency or handler timeouts.
The workflow process runs separately from HTTP instances. Heavy workflow dependencies do not have to occupy every request-serving process, while the worker still receives the app’s runtime variables, secrets, storage bindings, and structured logs.
If a worker exits with an error before it can claim any work, Tako stops the immediate respawn loop and makes the next enqueue fail with the startup error. Fixing the worker lets the next successful claim restore normal operation.
Storage And Multiple Servers
On one server, Tako stores durable workflow state locally. Runs belong to the deployed app and environment, not to one worker process or release, so a worker restart or rolling deploy does not discard progress.
Local workflow transactions synchronize the SQLite write-ahead log before acknowledging changes, including enqueues and saved steps. Acknowledged state survives a process or host crash when the filesystem and storage device honor synchronization requests. This does not protect against loss of the disk or server; configure backups for recovery. External step side effects still have the at-least-once contract described above.
Each claim receives a distinct ownership identifier. After a lease is reclaimed, a delayed earlier attempt cannot heartbeat, save a step, or change the new attempt’s run state, even when the same process claims the run again. This protects workflow state; external side effects still need application-level idempotency.
Tako keeps finished runs for at least 184 days by default, covering six calendar months, then removes them and their saved steps in small batches. The age is measured from completion, so long sleeps and waits do not shorten a run’s history. Cleanup is best effort while the workflow runtime is active; stopped or retired stores can keep older rows until the runtime starts again. To change the age or keep history indefinitely, set retention in tako.toml:
[workflows]
retention = "1d" # or "forever"
The setting applies to succeeded, cancelled, and dead runs. It does not delete pending or running runs. SQLite and Postgres can reuse freed database space even when the database file does not immediately shrink.
An environment deployed to multiple servers needs one of two explicit storage models:
| Project shape | Required setup |
|---|---|
| All workflows are global | Set the environment’s postgres_url credential for shared workflow state. |
| Every JavaScript workflow is intentionally local | Set local: true on every workflow to keep separate state on each server. |
| Local and global workflows are mixed | Set postgres_url; the environment needs shared workflow state. |
| Go workflow worker on multiple servers | Set postgres_url; Go workflows do not have the source-level local opt-out. |
Configure shared storage with:
tako credentials set postgres_url --env production
postgres_url is a provider credential. Tako encrypts it and does not expose it to app code. The SDK API remains the same whether Tako selects local storage or Postgres.
With local: true, each server owns its own queue and cron schedule. A scheduled workflow runs once per server, and run IDs do not deduplicate across servers. Use this only for work that is intentionally server-local, such as cleaning local files or warming a regional cache.
Tako validates the multi-server storage choice before build and deploy work begins. See Deployment for environment setup and the CLI reference for provider credential commands.
Development And Deployment
tako dev discovers JavaScript and TypeScript workflows under <app_root>/workflows/. The dev daemon owns the durable state and starts a separate scale-to-zero worker when work becomes runnable. Worker output appears in the same log stream as the app. Workflow definition changes refresh the runtime, and a fresh worker picks up the new code on subsequent work.
Cron schedules are synchronized from the current workflow definitions when a worker starts. Removing a schedule removes its registered cron entry on the next worker startup.
tako deploy validates the storage model, packages workflow definitions with the app, and installs the workflow runtime for the release. A new release replaces the previous worker runtime after in-flight work drains. Removing the workflows directory in a later release retires the old workflow runtime. Durable runs remain scoped to the app and environment across releases.
Use finite tako logs --env production --days 1 --json to inspect app and worker output with an agent; an attached terminal can follow it with --tail. See Operations for log inspection, Development for local runtime details, and Deployment for release behavior.
Practical Rules
- Keep workflow payloads and step results JSON serializable.
- Give every step a stable name. Changing a name creates a different checkpoint for existing runs.
- Make side-effecting step bodies idempotent.
ctx.runis durable but still at least once at the save boundary. - Supply
idto deduplicate enqueues for the same business operation while its run history is retained. - Use
ctx.bailfor expected cancellation andctx.failfor permanent failure. - Keep
signaland.enqueue()in server-side code. - Use Postgres for global workflows across multiple servers. Use
local: trueonly when once per server is intentional.
Verify
Enqueue a known payload, inspect the run/step logs, and check its application-side result. Force a retry in a test environment and confirm completed steps reuse saved results and external effects deduplicate. Test duplicate enqueue IDs, a signal or durable wait when used, and the intended shared-versus-per-server execution model.