Inngest
This page shows how Litestore runs its background work on Inngest and what happens when Inngest is missing or a job fails.
Litestore runs all of its background work on Inngest. Domain events, crons, durable sleeps and queued email all go through it. Read this page when you add a background function or need to know why scheduled work is not running.
Litestore builds the Inngest client in services/inngest.ts with an id and one middleware. It serves every function from one route, app/api/inngest/route.ts.
env.ts declares no Inngest key, so a store starts and serves pages without one. In that case the scheduled and queued work simply never runs. This is a deliberate trade-off, and most of this page is about what follows from it.
The client
The whole client is in services/inngest.ts.
The client takes its id from config.site.slug, which is "litestore" in config/site.ts. It adds one middleware that gives 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],
})Because of this middleware, a function handler can take db from its arguments instead of importing ~/services/db. cron.index-data.ts and cron.publish-social-posts.ts do this with async ({ step, db, logger }) => ….
The repository uses both styles. The middleware is what makes the first one work.
What the client leaves out
The client leaves out three things on purpose:
- It registers no event schemas.
- It uses no
EventSchemasgeneric. - It does not receive a signing key.
Instead, the code that sends each event enforces the payload types:
events/types.tsdoes this for domain events.- For other events, each function exports its own
type …Event, such asEmailSendEventinfunctions/email.send.ts,EmailBroadcastEventandReferralRewardHoldEvent.
The serve route
app/api/inngest/route.ts exports GET, POST and PUT from serve() and limits each run to 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, followed by domainEventFunctions. That spread adds one function per domain event type, 56 in total, each 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 jobs started by events:
agentVerify,platformFunctionFailedandreferralRewardHoldFunction
What maxDuration limits
maxDuration = 60 limits each invocation, not each job.
When a function splits its work into step.run calls, it gets 60 seconds for each step. A step.sleep ends the invocation entirely.
Jobs that call outside services one after another keep their batches small enough to fit. For this reason, cron.payment-reconcile.ts makes at most 25 Stripe lookups per run (RECONCILE_BATCH_SIZE).
Function ids
Function ids follow the pattern ${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 named domain-<event>, with the dot replaced by a dash:
domain-order-createdEnvironment variables
env.ts declares no Inngest variable.
The only Inngest variable the code reads is INNGEST_EVENT_KEY. events/emitter.ts reads it straight from process.env to decide whether the dev inline fallback applies. Nothing checks it when the app starts.
Without Inngest nothing breaks, but nothing scheduled runs
A deployment without Inngest still starts, serves the storefront and admin, and
saves orders normally. Everything that depends on inngest.send stops: queued
email, webhook delivery, cache clearing from domain events, all 13 crons and every
durable workflow. emitter.ts logs those failures and otherwise stays silent.
The dev inline fallback
On your computer, if bunx inngest-cli dev is not running and no event key is set, inngest.send fails. Events would then go nowhere, and caches would never be cleared.
events/emitter.ts detects exactly this case and runs the same handler code inside the app 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 Inngest may still have accepted a send that timed out. Running the handler inline as well could deliver the event twice.
Vitest is excluded because unit tests must not cause real side effects such as database writes and email sends.
What it runs
When the fallback applies, deliver() imports runDomainEventInline from functions/domain-events.ts at runtime. It then does the same three things the Inngest function would do:
invalidateCachesForDomainEventwebhookService.dispatchEvent- the handler body
It reuses the envelope the emitter created. The eventId that prevents duplicate side effects is therefore the same in both cases.
The import happens at runtime for two reasons. It keeps the server code out of the normal path, and it avoids a static import cycle between the emitter and functions/domain-events.
What it does not run
The fallback does not run workflows.
workflowsFor(eventType) needs a real step.sleep, and code running inline has no durable steps:
event emitted in dev, no event key
↓
inline: caches invalidated, webhooks dispatched, handler body runs
↓
workflows: skipped entirelyAs a result, the abandoned-cart emails, the review request and the onboarding nudge never run through the fallback. To test them locally, start the Inngest dev server.
Failure handling
Each function sets its own number of retries. There is no global setting:
retries: 3 email.send.ts, cron.currency-rates.ts
retries: 2 most crons
retries: 1 shipmentWatch, dailyWorkflowChecks
retries: 0 hourlyQuickChecksWhen a function has used up its retries, Inngest emits the system event inngest/function.failed.
functions/platform.failures.ts is the only listener for that event. It writes a platform_job_failed Activity row, so the failure no longer goes unnoticed. The platform-failure checks in server/admin/tasks/queries.ts count those rows as work for the operator.
To avoid a feedback loop, when the function that failed is this listener itself, it only logs the failure.
sendEmailFunction also has its own onFailure. It writes an email_failed Activity row and a final recordEmailLog entry. You can then find and retry a failed send in Litestore, instead of it sitting in an Inngest dashboard that nobody opens.
Copy this pattern when you add a background function. Inngest already handles retries, so don't ask whether the job retries. Ask this:
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.