BullMQ needs Redis. Cron has no retries. I needed background jobs that can sleep for 48 hours, fan out to parallel workers, and survive server restarts — without paying for infrastructure I don't have users to justify yet.
I replaced the entire Redis + BullMQ + cron + monitoring stack with five Inngest functions. Total cost: $0.
The Full Picture
Drippery — my drip email SaaS — runs five background functions. Together they cover scheduled email delivery, DNS domain verification polling, orphaned file cleanup, and beta user lifecycle.
All five are registered in one Next.js API route. Inngest calls it via webhook. No Redis, no worker process, no cron container.
Here's what that replaces:
| Without Inngest | With Inngest |
|---|---|
| Redis instance (~$10/month on Render) | Nothing |
| BullMQ worker process | Nothing |
| Cron container or external service | Nothing |
| Manual retry logic | Built-in, per-step |
| Observability dashboard | Inngest dashboard (free) |
| Distributed locking for cron | Handled automatically |
| Long-running job persistence | step.sleep() |
| Fan-out worker pool | step.sendEvent() |
Let me walk through each function.
The Email Pipeline: Scheduler + Parallel Sender
The core email system is a two-function pipeline. The scheduler runs every 15 minutes and asks: which subscribers are due for their next drip email?
export const sendPendingEmails = inngest.createFunction(
{ id: 'send-pending-emails', triggers: [cron('*/15 * * * *')] },
async ({ step }) => {
const subscribers = await step.run('fetch-subscribers', async () =>
findActiveSubscribersWithSequences()
);
// For each subscriber: find next due email, check dayOffset + send window
// Dispatch one event per match
await step.sendEvent('dispatch', eventsToDispatch);
}
);
It walks through each active subscriber, finds their position in the sequence, and checks if enough days have passed.
Creators in different timezones shouldn't get emails at 3 AM — the scheduler verifies the current time matches the tenant's preferred send window before dispatching anything.
The key decision: fan-out. Instead of sending emails inside the loop, the scheduler dispatches email/send events. Each event triggers a separate function invocation.
50 subscribers due = 50 parallel runs. No worker pool, no concurrency config.
The other critical detail: drip semantics. One email per subscriber per tick, never the whole sequence at once.
Each email/send event triggers the sender — one subscriber, one email, full isolation:
export const sendSingleEmail = inngest.createFunction(
{ id: 'send-single-email', triggers: [{ event: 'email/send' }] },
async ({ event, step }) => {
const { subscriberId, emailId, tenantId } = event.data;
const [subscriber, email, tenant] = await step.run('fetch-data', async () =>
fetchEmailContext(subscriberId, emailId, tenantId)
);
const finalHtml = replaceMergeTags(email.html, mergeTags);
await step.run('send-email', async () =>
sendEmail(subscriber.email, finalSubject, finalHtml)
);
await step.run('record-sent', async () =>
db.insert(sentEmails).values({ subscriberId, emailId, sentAt: new Date() })
);
}
);
Each step.run() is independently retryable. If the Resend API times out on send-email, only that step retries — the DB fetch doesn't re-execute.
If the server crashes after sending but before recording, Inngest resumes at record-sent. No double-sends, no lost records.
The 48-Hour Function: Domain Verification Polling
This is the function I couldn't have built with BullMQ — at least not without a lot of scheduling glue.
When a user adds a custom sender domain, Drippery registers it with Resend and starts polling for DNS verification. This can take 5 minutes or 24 hours — you don't know upfront.
export const checkDomainVerification = inngest.createFunction(
{ id: 'check-domain-verification', triggers: [{ event: 'domain/check-verification' }] },
async ({ event, step }) => {
const { senderDomainId, attempt = 1 } = event.data;
const MAX_ATTEMPTS = 576; // 48 hours at 5-minute intervals
const result = await step.run('check-resend', async () => {
const resend = new Resend(process.env.RESEND_API_KEY);
return resend.domains.get(domain.resendDomainId);
});
if (isVerified) return { status: 'verified', attempts: attempt };
await step.sleep('wait', '5m');
await step.sendEvent('next-check', {
name: 'domain/check-verification',
data: { senderDomainId, attempt: attempt + 1 },
});
}
);
This function can run for up to 48 hours. step.sleep() suspends the function, frees server resources, then resumes exactly where it left off.
With BullMQ, you'd need explicit delayed job scheduling, persistence logic, and retry handling. Here it's one line: await step.sleep('5m').
The Maintenance Jobs: Cleanup + Beta Lifecycle
The remaining two functions are simpler but follow the same pattern.
Orphan image cleanup runs every Sunday at 3 AM. It compares images in Cloudflare R2 against database references and deletes anything unreferenced:
export const cleanupOrphanImages = inngest.createFunction(
{ id: 'cleanup-orphan-images', triggers: [cron('0 3 * * 0')] },
async ({ step }) => {
const r2Keys = await step.run('list-r2', async () => listAllObjects());
const dbKeys = await step.run('get-refs', async () => extractReferencedUrls());
const orphans = r2Keys.filter(k => !dbKeys.has(k));
await step.run('delete', async () => deleteObjects(orphans));
}
);
Email images are excluded — once delivered, they can't be deleted without breaking past recipients' inboxes.
Beta expiry runs daily at 9 AM. It warns testers 3 days before their 14-day trial ends, then downgrades them:
export const checkBetaExpiry = inngest.createFunction(
{ id: 'check-beta-expiry', triggers: [cron('0 9 * * *')] },
async ({ step }) => {
const warningTesters = await step.run('find-warning', async () =>
findTenantsByBetaWindow(3, 4)
);
for (const tenant of warningTesters) {
await step.run(`warn-${tenant.id}`, async () => {
await sendWarningEmail(tenant);
await markWarned(tenant.id);
});
}
}
);
Each tenant gets its own step.run(). If one tenant's email lookup fails, only that step retries — the rest still get processed.
The Cost
Inngest's free tier gives you 50,000 executions per month. My actual usage is well under 10,000 — scheduler ticks, dispatched sends, daily and weekly jobs.
For an early-stage SaaS, the free tier has plenty of headroom.
When I outgrow it, paid plans start at $75/month. By that point, I'll have enough paying users to cover it. That's the scaling curve I wanted.
What's Next
Five functions today. The next one will probably be a subscriber re-engagement job — if someone hasn't opened the last 3 emails, automatically pause their sequence and notify the tenant.
Same pattern: one more function file, zero infrastructure changes.
The entire background job layer of my SaaS is five TypeScript functions, one API route, and zero managed services. When you're building alone, that's the architecture that lets you ship.
If you're curious about Drippery — dead-simple drip email sequences for creators, starting at $0/month — check it out at drippery.app.
I write weekly about what I'm shipping and what's breaking on Substack.
Top comments (0)