Inngest Complete Guide | Event-Driven Background Jobs and Workflows
이 글의 핵심
Inngest runs reliable background jobs and multi-step workflows without managing queues or workers. Functions are plain TypeScript, retries are automatic, and the local dev server shows every execution. This guide covers everything from simple jobs to complex AI pipelines.
What is Inngest?
Inngest lets you run background jobs and workflows without queues or workers:
Traditional: Your app → Push to Redis queue → Worker polls queue → Run job
Inngest: Your app → Send event → Inngest calls your function → Done
You write TypeScript functions. Inngest handles:
- Retries with exponential backoff
- Concurrency limits
- Rate limiting
- Scheduling (cron)
- Fan-out (parallel steps)
- Step state (pause/resume between steps)
Installation
npm install inngest
# Start local dev server (separate terminal)
npx inngest-cli@latest dev
1. Basic Setup
Next.js App Router
// app/api/inngest/route.ts
import { serve } from 'inngest/next'
import { inngest } from '@/lib/inngest'
import { sendWelcomeEmail, processPayment } from '@/lib/functions'
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail, processPayment],
})
// lib/inngest.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({
id: 'my-app',
// eventKey and signingKey come from environment variables in production
})
Express / Node.js
import { serve } from 'inngest/express'
import express from 'express'
import { inngest } from './inngest'
import { functions } from './functions'
const app = express()
app.use('/api/inngest', serve({ client: inngest, functions }))
app.listen(3000)
2. Simple Background Functions
import { inngest } from './inngest'
// Define an event-triggered function
export const sendWelcomeEmail = inngest.createFunction(
{ id: 'send-welcome-email' }, // unique function ID
{ event: 'user/signed-up' }, // trigger event
async ({ event, step }) => {
const { userId, email, name } = event.data
// Each step.run() is independently retried
await step.run('send-email', async () => {
await emailService.send({
to: email,
subject: `Welcome, ${name}!`,
template: 'welcome',
data: { name },
})
})
await step.run('update-crm', async () => {
await crm.users.update(userId, { emailSent: true })
})
return { success: true, userId }
}
)
Send an event from your app
// In your signup handler
await inngest.send({
name: 'user/signed-up',
data: {
userId: user.id,
email: user.email,
name: user.name,
},
})
// Send multiple events at once
await inngest.send([
{ name: 'user/signed-up', data: { userId: '1', email: 'alice@example.com', name: 'Alice' } },
{ name: 'analytics/track', data: { event: 'signup', userId: '1' } },
])
3. Step Functions — Multi-Step Workflows
export const onboardNewUser = inngest.createFunction(
{ id: 'onboard-new-user' },
{ event: 'user/signed-up' },
async ({ event, step }) => {
const { userId, email, plan } = event.data
// Step 1: Create workspace
const workspace = await step.run('create-workspace', async () => {
return await db.workspaces.create({ ownerId: userId, plan })
})
// Step 2: Send welcome email (runs even if step 3 fails)
await step.run('send-welcome-email', async () => {
await sendEmail(email, 'welcome', { workspaceId: workspace.id })
})
// Step 3: Wait 1 day, then send onboarding tips
await step.sleep('wait-for-onboarding', '1d')
// Checks if user completed setup before sending tips
const userActivity = await step.run('check-activity', async () => {
return await db.users.getActivity(userId)
})
if (!userActivity.completedSetup) {
await step.run('send-tips-email', async () => {
await sendEmail(email, 'onboarding-tips')
})
}
// Step 4: After 7 days, check if user upgraded
await step.sleep('wait-for-conversion', '7d')
const user = await step.run('check-plan', async () => {
return await db.users.findById(userId)
})
if (user.plan === 'free') {
await step.run('send-upgrade-nudge', async () => {
await sendEmail(email, 'upgrade-offer')
})
}
return { onboarded: true }
}
)
4. Parallel Steps with fan-out
export const generateReport = inngest.createFunction(
{ id: 'generate-monthly-report' },
{ event: 'reports/requested' },
async ({ event, step }) => {
const { reportId, sections } = event.data
// Run all sections in parallel
const results = await step.run('fetch-all-data', async () => {
return await Promise.all([
fetchSalesData(reportId),
fetchUserMetrics(reportId),
fetchRevenueData(reportId),
fetchChurnData(reportId),
])
})
const [sales, users, revenue, churn] = results
// Generate PDF after all data is ready
const pdfUrl = await step.run('generate-pdf', async () => {
return await pdfService.generate({ sales, users, revenue, churn })
})
await step.run('notify-requester', async () => {
await sendEmail(event.data.requestedBy, 'report-ready', { pdfUrl })
})
return { pdfUrl }
}
)
Fan-out — trigger multiple functions from one event
// One event → multiple functions run in parallel
export const processOrder = inngest.createFunction(
{ id: 'process-order' },
{ event: 'order/created' },
async ({ event }) => { /* fulfill order */ }
)
export const sendOrderConfirmation = inngest.createFunction(
{ id: 'send-order-confirmation' },
{ event: 'order/created' }, // same event!
async ({ event }) => { /* send email */ }
)
export const updateInventory = inngest.createFunction(
{ id: 'update-inventory' },
{ event: 'order/created' }, // same event!
async ({ event }) => { /* update stock */ }
)
// All three run in parallel when order/created is sent
5. Retries and Error Handling
export const processPayment = inngest.createFunction(
{
id: 'process-payment',
retries: 5, // default: 3, max: 20
},
{ event: 'payment/initiated' },
async ({ event, step, attempt }) => {
console.log(`Attempt ${attempt + 1}`) // 0-indexed
const result = await step.run('charge-card', async () => {
const charge = await stripe.charges.create({
amount: event.data.amount,
currency: 'usd',
source: event.data.token,
})
if (charge.status !== 'succeeded') {
throw new Error(`Payment failed: ${charge.failure_message}`)
// Throwing causes automatic retry with exponential backoff
}
return charge
})
// Non-retriable errors — don't retry
// throw new NonRetriableError('Card permanently declined')
return { chargeId: result.id }
}
)
6. Concurrency and Rate Limiting
export const processImport = inngest.createFunction(
{
id: 'process-csv-import',
concurrency: {
limit: 3, // max 3 concurrent executions globally
// Or per-user concurrency:
key: 'event.data.userId', // 1 import per user at a time
},
rateLimit: {
limit: 10, // max 10 events
period: '1m', // per minute
key: 'event.data.userId',
},
},
{ event: 'import/started' },
async ({ event, step }) => {
// Process CSV rows
const rows = await step.run('parse-csv', async () => {
return parseCsv(event.data.fileUrl)
})
for (const chunk of chunkArray(rows, 100)) {
await step.run(`process-chunk-${chunk[0].id}`, async () => {
await db.records.createMany({ data: chunk })
})
}
}
)
7. Scheduled Functions (Cron)
// Run every day at 9 AM UTC
export const dailyDigest = inngest.createFunction(
{ id: 'daily-digest-email' },
{ cron: '0 9 * * *' },
async ({ step }) => {
const activeUsers = await step.run('get-active-users', async () => {
return await db.users.findActive()
})
// Send digest to each user (fan-out)
for (const user of activeUsers) {
await step.run(`send-digest-${user.id}`, async () => {
const digest = await buildDigest(user.id)
await sendEmail(user.email, 'daily-digest', digest)
})
}
return { sentTo: activeUsers.length }
}
)
// Other cron examples
// '*/5 * * * *' → every 5 minutes
// '0 0 * * 1' → every Monday at midnight
// '0 0 1 * *' → first day of every month
8. AI Workflow (LLM Pipeline)
export const generateBlogPost = inngest.createFunction(
{
id: 'generate-blog-post',
timeouts: { finish: '10m' }, // LLM calls can be slow
},
{ event: 'content/requested' },
async ({ event, step }) => {
const { topic, userId } = event.data
// Step 1: Generate outline
const outline = await step.run('generate-outline', async () => {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'user', content: `Create an outline for a blog post about: ${topic}` }
],
})
return response.choices[0].message.content
})
// Step 2: Generate each section in parallel
const sections = outline.split('\n').filter(Boolean)
const content = await step.run('generate-content', async () => {
return await Promise.all(
sections.map(section =>
openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: `Write the "${section}" section...` }],
}).then(r => r.choices[0].message.content)
)
)
})
// Step 3: Save to database
const post = await step.run('save-post', async () => {
return await db.posts.create({
userId,
topic,
content: content.join('\n\n'),
status: 'draft',
})
})
// Step 4: Notify user
await step.run('notify-user', async () => {
await sendEmail(userId, 'post-ready', { postId: post.id })
})
return { postId: post.id }
}
)
Local Development
# Start your app
npm run dev # runs on port 3000
# Start Inngest dev server (separate terminal)
npx inngest-cli@latest dev
# Open Inngest dashboard
open http://localhost:8288
# The dashboard shows:
# - All events received
# - Function executions
# - Each step's input/output
# - Retry history
# - Timeline view
Key Takeaways
- No queue infrastructure — Inngest calls your functions via HTTP; no Redis workers to manage
- Steps — each
step.run()is independently retried; state persists between steps step.sleep()— pause a workflow for minutes, hours, or days without holding a connection- Fan-out — multiple functions can listen to the same event and run in parallel
- Concurrency + rate limiting — per-key limits prevent overwhelming external APIs
- Cron — schedule recurring jobs with standard cron syntax
- Local dev —
npx inngest-cli devgives a full dashboard with step-by-step inspection