{"slug":"self-hosted-cron-jobs-in-typescript-without-redis","url":"https://tako.sh/blog/self-hosted-cron-jobs-in-typescript-without-redis/","canonical":"https://tako.sh/blog/self-hosted-cron-jobs-in-typescript-without-redis/","title":"Self-Hosted Cron Jobs in TypeScript Without Redis","date":"2026-05-08T02:39","description":"Build durable TypeScript cron jobs with Tako workflows: scheduled runs, retries, dedupe, step checkpoints, and workers that scale to zero.","author":null,"image":"5b9490c58c98","imageAlt":null,"headings":[{"depth":2,"slug":"a-cron-job-is-really-a-queue","text":"A Cron Job Is Really A Queue"},{"depth":2,"slug":"write-the-scheduled-workflow","text":"Write The Scheduled Workflow"},{"depth":2,"slug":"make-retries-boring","text":"Make Retries Boring"},{"depth":2,"slug":"keep-the-worker-asleep","text":"Keep The Worker Asleep"}],"markdown":"Cron starts simple. Add `0 9 * * *`, run a script, call it a day.\n\nThen the script sends email, talks to an API, writes to your database, and sometimes fails halfway through. Now you need retries. You need to avoid duplicate sends. You need the job to survive a deploy. You need the worker to wake up at 9am, do the work, then stop burning memory.\n\nThe usual TypeScript answer is a queue stack. Redis is excellent for this: streams, sorted sets, and pub/sub make it a natural foundation for job systems. But if your cron job belongs to the same app you already deploy with Tako, adding Redis just to remember \"run this every morning, retry safely, and do not double-send\" can be more infrastructure than the job itself.\n\nTako workflows make that a built-in app primitive: TypeScript cron, durable runs, step checkpoints, deduping, and scale-to-zero workers on your own VPS. No separate queue service required.\n\n## A Cron Job Is Really A Queue\n\nA production cron job is not just a clock. The clock is the trigger; the durable queue is what makes the trigger safe.\n\n| Job need         | Redis-backed queue stack                        | Tako workflow                                          |\n| ---------------- | ----------------------------------------------- | ------------------------------------------------------ |\n| Schedule         | External scheduler or delayed-set polling       | `schedule` on `defineWorkflow`                         |\n| Durable state    | Redis persistence or another database           | Per-app SQLite at `{tako_data_dir}/apps/<app>/runs.db` |\n| Dedupe           | Job id / uniqueness key in queue library        | `uniqueKey`, plus internal cron keys                   |\n| Retries          | Worker library retry policy                     | Run-level and step-level retries                       |\n| Worker lifecycle | Separate worker process to deploy and supervise | Tako-supervised worker, scale-to-zero by default       |\n\nTako's workflow state is owned by `tako-server`, not the SDK. Your HTTP app and worker talk to the shared internal Unix socket; the server inserts runs, stores completed steps, ticks schedules, reclaims expired leases, and wakes workers. The full workflow architecture is documented in [the Tako docs](/docs/), and the worker knobs live in [the `tako.toml` reference](/docs/tako-toml/).\n\n```d2\ndirection: right\n\nclock: \"cron clock\" {style.fill: \"#FFF9F4\"; style.stroke: \"#2F2A44\"; style.font-size: 16}\nschedules: \"schedules table\" {style.fill: \"#9BC4B6\"; style.font-size: 16}\nticker: \"tako-server ticker\" {style.fill: \"#E88783\"; style.font-size: 16}\nruns: \"runs.db\" {style.fill: \"#FFF9F4\"; style.stroke: \"#2F2A44\"; style.font-size: 16}\nsupervisor: \"worker supervisor\" {style.fill: \"#9BC4B6\"; style.font-size: 16}\nworker: \"TypeScript workflow\" {style.fill: \"#E88783\"; style.font-size: 16}\n\nclock -> ticker: \"every second\"\nworker -> schedules: \"register schedule on boot\"\nticker -> runs: \"enqueue due run + uniqueKey\"\nruns -> supervisor: \"wake\"\nsupervisor -> worker: \"spawn if workers = 0\"\nworker -> runs: \"claim / save steps / complete\"\n```\n\nThat separation matters. The SDK never opens SQLite, so your app code does not carry queue file locking rules around. The server is the one place that knows how to enqueue, claim, heartbeat, persist steps, retry, and recover a run that was stuck in `running` after a worker died.\n\n## Write The Scheduled Workflow\n\nCreate a file under `src/workflows/`. The filename and workflow name match; flat files are discovered by the worker.\n\n```ts\n// src/workflows/daily-digest.ts\nimport { defineWorkflow } from \"tako.sh\";\nimport { db } from \"../db\";\nimport { mailer } from \"../mailer\";\n\nexport default defineWorkflow(\"daily-digest\", {\n  // 9:00 UTC every day.\n  schedule: \"0 9 * * *\",\n  retries: 4,\n  backoff: { base: 10_000, max: 15 * 60_000 },\n  handler: async (_payload, ctx) => {\n    const digestDate = await ctx.run(\"digest-date\", async () =>\n      new Date().toISOString().slice(0, 10),\n    );\n\n    const targets = await ctx.run(\"prepare-targets\", async () =>\n      db.digestSend.createMissingForDate(digestDate),\n    );\n\n    for (const target of targets) {\n      await ctx.run(\n        `send:${target.id}`,\n        () =>\n          mailer.sendDigest(target.email, {\n            digestDate,\n            idempotencyKey: `digest:${target.id}`,\n          }),\n        { retries: 3, backoff: { base: 2_000, max: 30_000 } },\n      );\n    }\n\n    await ctx.run(\"mark-complete\", () => db.digestRun.markComplete(digestDate));\n  },\n});\n```\n\nThe cron run payload is `{}`, so a pure scheduled job usually ignores `_payload`. If you also want to trigger the same workflow manually, import the workflow handle from server-side code and enqueue it yourself:\n\n```ts\nimport dailyDigest from \"../workflows/daily-digest\";\n\nawait dailyDigest.enqueue(\n  {},\n  { uniqueKey: `manual-digest:${new Date().toISOString().slice(0, 10)}` },\n);\n```\n\nThat `uniqueKey` is optional for manual runs, but useful when a button, webhook, or admin command might be retried. If another pending or running run already has the same key, enqueue returns the existing run id instead of inserting another row.\n\nCron runs get the same treatment internally. When the schedule fires, Tako enqueues with a key shaped like `cron:<name>:<bucket_ms>`. If a worker registers schedules twice, or the ticker loops across the same boundary twice, the key collapses the duplicate. If the server falls behind, the ticker fast-forwards and enqueues only the latest boundary that already passed, instead of flooding every missed minute.\n\n## Make Retries Boring\n\nThere are two retry layers in the example.\n\n`retries: 4` on the workflow means the whole handler gets up to four retries after the first attempt. If the handler throws, the run goes back to `pending` with exponential backoff and jitter. When the retry budget is exhausted, the run moves to `dead`.\n\n`ctx.run(..., { retries: 3 })` is smaller. It retries that one side effect before the error escapes to the run-level retry policy. That is useful for a flaky mail API where a quick retry is often enough, while still keeping the whole workflow durable if the process crashes.\n\nThe checkpoint is the important part. `ctx.run(\"prepare-targets\", ...)` stores its result in the `steps` table. On the next attempt, Tako returns the stored result and skips the database write. `ctx.run(\"send:<id>\", ...)` does the same for each recipient that already finished.\n\n| Practice                            | Why it matters                                                                                              |\n| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Use stable step names               | A retried run can find completed work by name.                                                              |\n| Make side effects idempotent        | Workflows are at-least-once if a worker dies after the side effect but before the checkpoint RPC completes. |\n| Put dedupe in your domain too       | The mailer's `idempotencyKey` or a DB unique key protects the outside world.                                |\n| Use `ctx.fail` for permanent errors | Skip retries when the input can never succeed.                                                              |\n| Use `ctx.bail` for obsolete work    | End cleanly when the job is no longer needed.                                                               |\n\nThe contract is honest: durable execution cannot make arbitrary side effects exactly-once. Tako gives you first-write-wins checkpoints, run dedupe, retries, and lease recovery; your step body should still use upserts, idempotency keys, and stable business identifiers.\n\n## Keep The Worker Asleep\n\nFor most cron jobs, the best worker is no worker at all until the clock fires.\n\nScale-to-zero is the default when an app has a `src/workflows/` directory:\n\n```toml\n# tako.toml\nname = \"digest-app\"\n\n[workflows]\nworkers = 0\nconcurrency = 10\n```\n\nWith `workers = 0`, `tako-server` keeps the queue, schedules, and ticker alive. On the first enqueue or cron tick, the supervisor spawns one worker process. The worker claims due runs, processes them up to `concurrency`, and exits after its idle window. In production that idle window is five minutes; under `tako dev` it is shorter so code changes take effect on the next enqueue.\n\nIf your job runs constantly, pin workers up:\n\n```toml\n[workflows]\nworkers = 1\nconcurrency = 20\n```\n\nNamed groups work too. Put noisy email jobs in `worker: \"email\"`, then give `[workflows.email]` its own process count. The deployment docs cover the broader release flow in [deployment](/docs/deployment/), and the local loop is described in [development](/docs/development/).\n\nTo try it locally:\n\n```bash\ntako dev\n```\n\nEdit `src/workflows/daily-digest.ts`, trigger a manual enqueue from a server route if you want to test immediately, and watch the worker logs in the same terminal stream as the HTTP app. When the scheduled time arrives, the dev server uses the same architecture as production: server-owned queue, internal socket, supervised worker.\n\nTo ship it:\n\n```bash\ntako deploy production\n```\n\nThe workflow file deploys with the app. Secrets are available to the worker the same way they are available to HTTP instances, and the worker process is separate, so workflow-only dependencies do not bloat request handling. The CLI surface is in [the CLI reference](/docs/cli/).\n\nSelf-hosted cron should feel like application code, not like a small distributed systems project you accidentally adopted. Put the schedule next to the handler, name the steps that matter, make side effects idempotent, and let Tako keep the clock, queue, retries, dedupe, and sleeping worker lifecycle together on your VPS."}