Reliable Background OCR Processing with Inngest and Next.js

Mohamed Almadih

Reliable Background OCR Processing with Inngest and Next.js
Processing files with AI can be unpredictable. When a user uploads ten bank receipts at once, performing OCR on each one sequentially within a single request is a recipe for disaster: you'll hit serverless timeouts, memory limits, and provide a terrible user experience.
In this post, I'll explain how we used Inngest to build a durable, event-driven background processing pipeline.
The Challenge: The Serverless "Wall"
Standard Next.js Server Actions or API routes have strict execution limits (usually 10-60 seconds on platforms like Vercel). If you try to process multiple images using a heavy LLM like Mistral:
- Timeouts: The request will likely get killed before the 3rd or 4th image is done.
- UX: The user has to wait with a loading spinner for the entire duration.
- Reliability: If one image fails, do you roll back the others? What if the network blips mid-way?
Enter Inngest: Event-Driven Next.js
Inngest allows us to trigger "events" that run background functions outside the main request thread. It handles retries, state management, and orchestration automatically.
1. Defining the Event Schema
First, we define what a "process receipt" event looks like. This gives us type safety across our app.
1// src/inngest/client.ts2export const inngest = new Inngest({3id: "receipt-processor",4schemas: new EventSchemas().fromRecord<{5"receipt/process": {6data: {7userId: string;8fileKey: string;9type: string;10category: string;11originalName: string;12};13};14}>(),15});
2. The Background Function (The "Worker")
The core logic lives in an Inngest function. We use step.run to break the process into durable steps. If the "process-receipt" step fails, Inngest will retry it without re-downloading the file.
1// src/inngest/functions/process-receipt.ts2export const processReceipt = inngest.createFunction(3{ id: "process-receipt" },4{ event: "receipt/process" },5async ({ event, step }) => {6const { userId, fileKey, type, category, originalName } = event.data;78// Step 1: Download from R29const buffer = await step.run("download-file", async () => {10const url = await getSignedUrlForDownload(fileKey);11const response = await fetch(url);12const arrayBuffer = await response.arrayBuffer();13return Buffer.from(arrayBuffer).toString("base64");14});1516// Step 2: Perform AI OCR and Save17const result = await step.run("process-receipt-ai", async () => {18const fileBuffer = Buffer.from(buffer, "base64");19return await processReceiptFromBuffer({20userId,21buffer: fileBuffer,22fileKey,23type,24category,25originalName,26});27});2829return result;30}31);
3. Triggering in Bulk
When a user performs a bulk upload, we don't process anything immediately. Instead, we upload the files to R2 and then fire off multiple events. This happens almost instantly from the user's perspective.
1// src/app/(dashboard)/dashboard/actions.ts2export async function bulkUploadReceipts(formData: FormData) {3// ... auth and limit checks ...45const uploadPromises = files.map(async (file) => {6const buffer = Buffer.from(await file.arrayBuffer());7const key = `temp_${crypto.randomUUID()}_${userId}.${extension}`;8await uploadFile(buffer, key);910return { name: "receipt/process", data: { userId, fileKey: key, ... } };11});1213const events = await Promise.all(uploadPromises);1415// Send all events to Inngest at once16await inngest.send(events);1718return { success: true, message: `Processing ${files.length} receipts in the background...` };19}
Why This Wins
- Instant Feedback: The user sees a "Processing..." message immediately. They can navigate away or even close the tab.
- Parallelism: Inngest can trigger multiple instances of the function in parallel, processing 10 receipts much faster than a sequential loop would.
- Durability: If the AI model is down or rate-limited, Inngest will automatically retry with exponential backoff.
- Observability: You get a beautiful dashboard to see exactly which step failed and why, with full logs and payload inspection.
Conclusion
By moving heavy AI processing to Inngest, we've made the app significantly more robust and responsive. It's the difference between an application that feels like a toy and one that handles real-world workloads reliably.