Self-Hosted Cron Jobs in TypeScript Without Redis

Self-Hosted Cron Jobs in TypeScript Without Redis

Tako-kun ·

Cron starts simple. Add 0 9 * * *, run a script, call it a day.

Then 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.

The 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.

Tako 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.

A Cron Job Is Really A Queue

A production cron job is not just a clock. The clock is the trigger; the durable queue is what makes the trigger safe.

Job needRedis-backed queue stackTako workflow
ScheduleExternal scheduler or delayed-set pollingschedule on defineWorkflow
Durable stateRedis persistence or another databasePer-app SQLite at {tako_data_dir}/apps/<app>/runs.db
DedupeJob id / uniqueness key in queue libraryuniqueKey, plus internal cron keys
RetriesWorker library retry policyRun-level and step-level retries
Worker lifecycleSeparate worker process to deploy and superviseTako-supervised worker, scale-to-zero by default

Tako’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, and the worker knobs live in the tako.toml reference.

Diagram

That 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.

Write The Scheduled Workflow

Create a file under src/workflows/. The filename and workflow name match; flat files are discovered by the worker.

// src/workflows/daily-digest.ts
import { defineWorkflow } from "tako.sh";
import { db } from "../db";
import { mailer } from "../mailer";

export default defineWorkflow("daily-digest", {
  // 9:00 UTC every day.
  schedule: "0 9 * * *",
  retries: 4,
  backoff: { base: 10_000, max: 15 * 60_000 },
  handler: async (_payload, ctx) => {
    const digestDate = await ctx.run("digest-date", async () =>
      new Date().toISOString().slice(0, 10),
    );

    const targets = await ctx.run("prepare-targets", async () =>
      db.digestSend.createMissingForDate(digestDate),
    );

    for (const target of targets) {
      await ctx.run(
        `send:${target.id}`,
        () =>
          mailer.sendDigest(target.email, {
            digestDate,
            idempotencyKey: `digest:${target.id}`,
          }),
        { retries: 3, backoff: { base: 2_000, max: 30_000 } },
      );
    }

    await ctx.run("mark-complete", () => db.digestRun.markComplete(digestDate));
  },
});

The 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:

import dailyDigest from "../workflows/daily-digest";

await dailyDigest.enqueue(
  {},
  { uniqueKey: `manual-digest:${new Date().toISOString().slice(0, 10)}` },
);

That 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.

Cron 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.

Make Retries Boring

There are two retry layers in the example.

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.

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.

The 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.

PracticeWhy it matters
Use stable step namesA retried run can find completed work by name.
Make side effects idempotentWorkflows are at-least-once if a worker dies after the side effect but before the checkpoint RPC completes.
Put dedupe in your domain tooThe mailer’s idempotencyKey or a DB unique key protects the outside world.
Use ctx.fail for permanent errorsSkip retries when the input can never succeed.
Use ctx.bail for obsolete workEnd cleanly when the job is no longer needed.

The 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.

Keep The Worker Asleep

For most cron jobs, the best worker is no worker at all until the clock fires.

Scale-to-zero is the default when an app has a src/workflows/ directory:

# tako.toml
name = "digest-app"

[workflows]
workers = 0
concurrency = 10

With 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.

If your job runs constantly, pin workers up:

[workflows]
workers = 1
concurrency = 20

Named 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, and the local loop is described in development.

To try it locally:

tako dev

Edit 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.

To ship it:

tako deploy production

The 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.

Self-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.