Litestore · Developer guide
Background work

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.

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

services/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 ~/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 EventSchemas generic.
  • It does not receive a signing key.

Instead, the code that sends each event enforces the payload types:

  • events/types.ts does this for domain events.
  • For other events, each function exports its own type …Event, such as EmailSendEvent in functions/email.send.ts, EmailBroadcastEvent and ReferralRewardHoldEvent.

The 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,
    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.ts and email.broadcast.ts
  • three jobs started by events: agentVerify, platformFunctionFailed and referralRewardHoldFunction

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

Two 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-created

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

events/emitter.ts
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:

  1. invalidateCachesForDomainEvent
  2. webhookService.dispatchEvent
  3. 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 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.

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   hourlyQuickChecks

When 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?

On this page