Litestore · Developer docs
Background work

Inngest

Litestore runs its background jobs on Inngest, and the jobs fall back to defined behavior when Inngest isn't configured 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 find out why scheduled work is not running.

Postgres 15+Your data
RedisCache and rate limits
S3 storageImages
LitestoreOne Next.js app
StorefrontAdmin
ResendEmail and sign-in links
InngestBackground jobs
StripeOptional
Litestore is one Next.js app, and Inngest is the service it hands its background jobs to.

Litestore builds the Inngest client in platform/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 domain events and queued email run inline inside the app, but crons and durable workflows don't run. This is a deliberate trade-off, and most of the sections below cover what follows from it.

inngest.ts
emitter.ts
types.ts
route.ts
domain-events.ts
platform.failures.ts
email.send.ts

Client

The client takes its id from config.site.slug, which is "litestore" in platform/config/site.ts. It adds one middleware that gives every function run the shared Prisma client:

platform/inngest.ts
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 ~/platform/db/client. cron.index-data.ts does this with async ({ step, db, logger }) => …. The repository uses both styles, and the middleware is what makes the first one work.

Client options

The client registers no event schemas, uses no EventSchemas generic and does not receive a signing key. Litestore leaves these out on purpose.

Instead, the code that sends each event enforces the payload types. platform/events/types.ts does this for domain events. For other events, each function exports its own type …Event, such as EmailSendEvent in jobs/email.send.ts, EmailBroadcastEvent and ReferralRewardHoldEvent.

Serve route

app/api/inngest/route.ts exports GET, POST and PUT from serve() and limits each run to 60 seconds:

app/api/inngest/route.ts
export const maxDuration = 60

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [
    sendEmailFunction,
    sendBroadcastFunction,
    // …
    ...domainEventFunctions,
  ],
})

The array lists 14 named functions, followed by domainEventFunctions. That spread adds one function per domain event type, 53 in total, each built by createDomainEventHandler in jobs/domain-events.ts.

KindCountFunctions
Crons10jobs/cron.*
Email2email.send.ts, email.broadcast.ts
Event-started jobs2platformFunctionFailed, referralRewardHoldFunction
Domain events53domainEventFunctions

maxDuration

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-send

Domain-event functions are named domain-<event>, with the first dot replaced by a dash:

domain-order-created

Environment variables

env.ts declares no Inngest variable. The only Inngest variable the code reads is INNGEST_EVENT_KEY. platform/events/emitter.ts reads it straight from process.env to determine whether the inline fallback applies, and 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. Domain events still run, inline, so caches clear, webhooks go out and event handlers run, and queueEmail sends inline too. What stops is everything that needs Inngest itself: all 10 crons, every durable workflow and Inngest's retries.

Inline fallback

Without an Inngest event key, inngest.send fails, whether that's on your computer without bunx inngest-cli dev or on a self-hosted store that runs no Inngest. Events would then go nowhere, and caches would never be cleared. platform/events/emitter.ts detects exactly this case and runs the same handler code inside the app instead:

platform/events/emitter.ts
function isInlineFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
  return !env.INNGEST_EVENT_KEY && !env.VITEST
}

Both conditions matter. A configured key excludes the fallback because Inngest may still have accepted a send that timed out, and running the handler inline as well could deliver the event twice; without a key Inngest refuses every send, so nothing can be delivered twice. Vitest is excluded because unit tests must not cause real side effects such as database writes and email sends. In production without a key, the emitter skips the send and goes straight to the inline path; in development it tries a key-less dev server first.

Inline steps

When the fallback applies, the emitter imports runDomainEventInline from jobs/domain-events.ts at runtime. It then does the same three things the Inngest function would do.

Cache clearing

It calls invalidateCachesForDomainEvent.

Webhooks

It calls dispatchWebhookEvent.

Handler body

It runs the handler with the envelope the emitter created.

Because it reuses that envelope, the eventId that prevents duplicate side effects is 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 jobs/domain-events.

Skipped work

The fallback does not run workflows. workflowsFor(eventType) needs a real step.sleep, and code running inline has no durable steps:

event emitted, no event key
    ↓
inline: caches invalidated, webhooks dispatched, handler body runs
    ↓
workflows: skipped entirely

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

bunx inngest-cli dev

Events, crons and workflows all run as they would in production.

Failure handling

Each function sets its own number of retries, and there is no global setting:

RetriesFunctions
3email.send.ts, cron.currency-rates.ts
2Most crons, email.broadcast.ts, referral.reward.ts
1shipmentWatch, dailyWorkflowChecks, webhookRetries, platformFunctionFailed

When a function has used up its retries, Inngest emits the system event inngest/function.failed. jobs/platform.failures.ts is the only listener for that event. It writes a platform_job_failed Activity row, so the failure shows up in the admin activity history instead of going unnoticed.

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. A failed send then shows up in Litestore, where staff retry it, instead of 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?

On this page