Inngest
Learn how Litestore builds its Inngest client, what the /api/inngest route registers, why no Inngest environment variable is declared in env.ts, and what the dev inline fallback does when a send fails.
Inngest is the single background substrate. Domain events, crons, durable sleeps and async email all run through it.
Litestore builds the client in services/inngest.ts with an id and one middleware, and serves every function from one route, app/api/inngest/route.ts.
No Inngest key is declared in env.ts, so a store boots and serves without one. The scheduled and queued work just never runs — which is a deliberate trade, and the rest of this page is largely about its consequences.
The client
services/inngest.ts is the whole client.
It names itself after config.site.slug ("litestore" in config/site.ts) and attaches one middleware that hands every function run the shared Prisma client:
const prismaMiddleware = new InngestMiddleware({
name: "Prisma Middleware",
init: () => ({
onFunctionRun: () => ({
transformInput: () => ({ ctx: { db } }),
}),
}),
})
export const inngest = new Inngest({
id: config.site.slug,
middleware: [prismaMiddleware],
})That middleware is why function handlers can destructure db from their arguments — async ({ step, db, logger }) => … in cron.index-data.ts and cron.publish-social-posts.ts — instead of importing ~/services/db.
Both styles are in the repository. The middleware is what makes the destructured one work.
What the client does not do
Three things are absent on purpose:
- No event schema registration
- No
EventSchemasgeneric - No signing key passed here
Event payload types are enforced at the producer instead:
events/types.tsfor domain events- A per-function
type …Eventexport for the rest —EmailSendEventinfunctions/email.send.ts,EmailBroadcastEvent,ReferralRewardHoldEvent
The serve route
app/api/inngest/route.ts exports GET, POST and PUT from serve() and caps execution at 60 seconds:
export const maxDuration = 60
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [
sendEmailFunction,
sendBroadcastFunction,
publishSocialPosts,
// …
...domainEventFunctions,
],
})The array lists 18 named functions plus the spread of domainEventFunctions — one per domain event type, 56 of them, built by createDomainEventHandler in functions/domain-events.ts.
The 18 named functions are:
- 13 crons (
functions/cron.*) - Two email functions —
email.send.tsandemail.broadcast.ts - Three event-driven jobs —
agentVerify,platformFunctionFailedandreferralRewardHoldFunction
What maxDuration actually caps
maxDuration = 60 is the per-invocation budget, not the per-job budget.
A function that splits its work into step.run calls gets 60 seconds per step, and a step.sleep ends the invocation entirely.
Jobs that make serial external calls size their batches against it. cron.payment-reconcile.ts caps a run at 25 Stripe lookups (RECONCILE_BATCH_SIZE) for exactly this reason.
Function ids
Ids follow ${config.site.slug}.<name>:
litestore.data-cleanup
litestore.email-sendTwo functions do not use the prefix:
alert-digest (alertDigest)
agent-verify (agentVerify)Domain-event functions are domain-<event> with the dot replaced:
domain-order-createdEnvironment variables
env.ts declares no Inngest variable.
The only Inngest env name read anywhere in the source is INNGEST_EVENT_KEY, and it is read off process.env directly in events/emitter.ts to decide whether the dev inline fallback applies. It is never validated at boot.
Nothing fails without Inngest, and nothing scheduled runs
An unconfigured deployment boots, serves the storefront and admin, and commits
orders normally. Everything downstream of inngest.send stops — queued email,
webhook dispatch, cache invalidation from domain events, all 13 crons, every
durable workflow — and emitter.ts logs those failures, otherwise silently.
The dev inline fallback
Locally, with no bunx inngest-cli dev running and no event key set, inngest.send fails and events would go nowhere — including cache invalidation.
events/emitter.ts detects that exact case and runs the same handler bodies in process instead:
export function isInlineFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === "development" && !env.INNGEST_EVENT_KEY && !env.VITEST
}All three conditions matter.
Production is excluded because a timed-out send may still have been accepted by Inngest, so running inline there could double-deliver.
Vitest is excluded because unit tests must not execute real handler side effects — database writes and email sends.
What it runs
When it triggers, deliver() dynamically imports runDomainEventInline from functions/domain-events.ts and executes the same three things the Inngest function would:
invalidateCachesForDomainEventwebhookService.dispatchEvent- The handler body
It reuses the emitter's envelope, so the eventId that keys side-effect dedupe is identical either way.
The dynamic import exists for two reasons: to keep the server graph off the normal path, and to avoid a static emitter ⇄ functions/domain-events cycle.
What it does not run
The fallback does not run workflows.
workflowsFor(eventType) needs a real step.sleep, and inline execution has no durable step surface:
event emitted in dev, no event key
↓
inline: caches invalidated, webhooks dispatched, handler body runs
↓
workflows: skipped entirelySo the recovery drip, the review request and the onboarding nudge never fire from the fallback. Start the Inngest dev server to exercise them locally.
Failure handling
Retries are configured per function, not globally:
retries: 3 email.send.ts, cron.currency-rates.ts
retries: 2 most crons
retries: 1 shipmentWatch, dailyWorkflowChecks
retries: 0 hourlyQuickChecksWhen a function exhausts its retries, Inngest emits the system event inngest/function.failed.
functions/platform.failures.ts is the one listener for it. It turns that silent death into a platform_job_failed Activity row, which the platform-failure detections in server/admin/tasks/queries.ts count as operator work.
It guards against a feedback loop by logging only when the failed function id is itself the dead-letter listener.
sendEmailFunction adds its own onFailure on top. It writes an email_failed Activity row and a terminal recordEmailLog entry, so a dead send is queryable and retryable rather than an entry in an Inngest dashboard nobody opens.
That is the pattern worth copying when you add a background function. The useful question is not whether the job retries — Inngest handles that — it is:
When the last retry fails, who finds out?
Related
Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.
Crons
Learn what each of the 13 Inngest crons in functions/ does, the exact schedule it runs on, and why abandoned-cart recovery starts from a 30-minute poll rather than a durable timer.
Workflows
Learn how the three WORKFLOWS in lib/rules/workflows.ts use step.sleep for durable multi-touch follow-ups, how that differs from the polling crons, and what the other event-driven background functions do.
Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.
Crons
Learn what each of the 13 Inngest crons in functions/ does, the exact schedule it runs on, and why abandoned-cart recovery starts from a 30-minute poll rather than a durable timer.