Why thunks?
A thunk is “work packaged as a function”, which lets libraries decide when (or whether) the work runs.
type Thunk<T> = () => T;type AsyncThunk<T> = () => Promise<T>;Instead of running the work now, you pass a function that can run it later.
You already use thunks (React Query)
Section titled “You already use thunks (React Query)”If you’ve ever written queryFn: () => fetch(...), you’ve used a thunk.
TanStack React Query’s queryFn is an async thunk: you give it how to fetch, and React Query decides when to run it (based on cache, dedupe rules, refetching, focus, enabled, etc.).
import { useQuery } from "@tanstack/react-query";
function Todos({ userId }: { userId?: string }) { const query = useQuery({ queryKey: ["todos", userId], // thunk: don't fetch during render; let react-query schedule it queryFn: () => fetch(`/api/users/${userId}/todos`).then(r => r.json()), enabled: !!userId, });
// ...}The key idea: the library owns execution, so it needs your work as a thunk.
Promises start immediately (often too early)
Section titled “Promises start immediately (often too early)”Promises typically begin executing immediately when they’re created. A Promise represents a single, already-started execution, not a repeatable operation.
// Starts immediatelyconst promise = fetch("/api/user");
// By the time you pass it somewhere, you already lost control over start timeawait promise;With a thunk, the caller decides when to start:
// Starts only when calledconst fetchUser = () => fetch("/api/user");
await fetchUser();How this maps to awaitly step()
Section titled “How this maps to awaitly step()”In awaitly, the workflow runtime wants to wrap an operation with orchestration:
- caching / dedupe (skip work on cache hits)
- retries (re-run the operation)
- timeouts and cancellation
- observability (events, durations)
- resume / replay (skip already-completed work)
To do that reliably, step() needs “work I can start later” — a thunk:
const user = await step('fetchUser', () => fetchUser('1'), { key: 'user:1' });Why step('id', somePromise) may “work”
Section titled “Why step('id', somePromise) may “work””Sometimes you’ll see code like this:
// Looks like it should be fine...const user = await step('fetchUser', fetchUser('1'), { key: 'user:1' });It may “work” in the narrow sense that:
- you still get the value on success (or an error on failure)
- you may still see results recorded (cache populated,
step_completeemitted) depending on the call path
But it breaks the reason step() exists: execution control.
When you pass a Promise, the operation has already started before step() can do anything.
That means the runtime can’t reliably:
- skip execution on cache hit (the request already started)
- retry by re-invoking the operation (you gave it a single already-running Promise)
- resume by avoiding already-completed work (it already started)
A simple rule of thumb
Section titled “A simple rule of thumb”- If you want orchestration features (caching, retries, resume): use
step('id', () => op(), opts). - If you just want the value and don’t care about orchestration: start it yourself and
awaitit, don’t hand an already-started Promise to an orchestrator.