Bulk Emails from a chat input β without Redis, queues, or worker services
Serverless Background Pipelines with Next.js 15 after() and Postgres
Current Situation Analysis
The default architecture for asynchronous work in modern web stacks has become heavily standardized: receive a request, push a payload to a message broker (Redis, RabbitMQ, SQS), spin up a worker process, and let the worker consume the queue. This pattern works flawlessly at scale, but it introduces significant operational overhead for small-to-medium batch workflows.
Developers frequently over-engineer background tasks because they conflate "async execution" with "distributed queueing." The reality is that most personal tools, internal dashboards, and low-volume automation scripts process fewer than 50 items per trigger. At this scale, the latency introduced by queue serialization, worker polling, and broker connection management often outweighs the benefits. Furthermore, serverless platforms like Vercel do not natively support long-running worker processes, forcing teams to either provision separate containers or pay for managed queue services that sit idle 99% of the time.
The misunderstanding stems from treating after() as a hack rather than a deliberate runtime feature. Next.js 15 exposes after() specifically to allow post-response execution within the same serverless invocation. When paired with a relational database's atomic update capabilities, you can build a lightweight state machine that replaces traditional queues entirely. For batch sizes under 20 items, this approach eliminates external dependencies, reduces cold-start overhead, and keeps the entire execution path within a single deployment unit.
WOW Moment: Key Findings
The following comparison illustrates why dropping the message broker for sub-50-item workflows is not just simpler, but often more reliable for serverless environments.
| Approach | Infrastructure Cost | Setup Complexity | Max Batch Size | Failure Surface | Operational Overhead |
|---|---|---|---|---|---|
| Queue-Driven (Redis + BullMQ/QStash) | $15β$50/mo + worker runtime | High (broker, worker, serializer, retry config) | Unlimited | Broker disconnects, worker crashes, serialization mismatches | Monitoring queues, scaling workers, managing dead-letter queues |
Runtime-Driven (after() + Postgres) |
$0 (uses existing function & DB) | Low (DB schema, route handler, atomic claim) | ~15β20 items (bounded by function timeout) | Invocation termination, DB connection limits | Connection pooling, timeout monitoring, idempotency keys |
Why this matters: You trade theoretical infinite scalability for immediate deployment simplicity and zero broker latency. For personal outreach, internal reporting, or scheduled batch updates, the runtime-driven approach delivers faster feedback loops, simpler debugging, and a dramatically smaller attack surface. The database becomes the source of truth for both state and ordering, eliminating the need to synchronize two systems.
Core Solution
Building a post-response pipeline requires four coordinated components: a detection gate, a state initialization layer, an atomic execution loop, and a client-side progress tracker. Below is the production-ready implementation strategy.
1. Route Detection & Authentication Gate
The entry point validates the input, checks authorization, and initializes the batch state. Instead of parsing raw strings inline, we extract recipients into a structured array and verify the trigger condition.
import { NextRequest, NextResponse } from 'next/server';
import { after } from 'next/server';
import { createOutreachBatch, scheduleBatchExecution } from '@/lib/pipeline';
import { verifyPassphrase } from '@/lib/auth';
export async function POST(req: NextRequest) {
const body = await req.json();
const { message, passphrase } = body;
const recipients = parseRecipientList(message);
const isTriggered = recipients.length >= 2 && containsJobContext(message);
if (!isTriggered) {
return NextResponse.json({ reply: 'Standard chat response.' });
}
const isAuthenticated = await verifyPassphrase(passphrase);
if (!isAuthenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const batchId = await createOutreachBatch({
jobDescription: message,
recipients,
});
// Schedule background work after response flush
after(() => scheduleBatchExecution(batchId));
return NextResponse.json({
reply: 'π€ Outreach pipeline initialized.',
batchId,
});
}
function parseRecipientList(text: string): string[] {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
return [...new Set(text.match(emailRegex) || [])];
}
2. Atomic Job Claiming & Processing Loop
Traditional queues rely on BRPOP or similar commands. In Postgres, we achieve the same guarantee using a single UPDATE ... RETURNING statement. This prevents race conditions if the serverless runtime retries the invocation or if multiple triggers overlap.
import { sql } from '@vercel/postgres';
export async function claimNextTask(batchId: string) {
const result = await sql`
UPDATE outreach_tasks
SET status = 'processing',
attempts = attempts + 1,
updated_at = NOW()
WHERE id = (
SELECT id
FROM outreach_tasks
WHERE batch_id = ${batchId}
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
)
RETURNING *;
`;
return result.rows[0] || null;
}
3. Structured LLM Generation & SMTP Dispatch
We use Groq's llama-3.3-70b-versatile with strict JSON mode. Zod validates the output before attempting SMTP delivery. This prevents malformed payloads from breaking the pipeline.
import { z } from 'zod';
import Groq from 'groq-sdk';
import nodemailer from 'nodemailer';
const EmailDraftSchema = z.object({
subject: z.string().min(5).max(100),
body: z.string().min(50),
});
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function generateAndSend(task: any, jobDescription: string) {
const domain = task.recipient.split('@')[1];
const fallbackSender = 'Hiring Team';
const company = domain.includes('gmail') || domain.includes('yahoo')
? fallbackSender
: domain.split('.')[0].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const completion = await groq.chat.completions.create({
model: 'llama-3.3-70b-versatile',
messages: [
{
role: 'system',
content: `Generate a professional outreach email. Company: ${company}. Job context: ${jobDescription}. Return ONLY valid JSON matching the schema.`,
},
],
response_format: { type: 'json_object' },
});
const raw = completion.choices[0]?.message?.content;
if (!raw) throw new Error('LLM returned empty response');
const draft = EmailDraftSchema.parse(JSON.parse(raw));
await transporter.sendMail({
from: process.env.SMTP_USER,
to: task.recipient,
subject: draft.subject,
html: formatBodyToHTML(draft.body),
});
await updateTaskStatus(task.id, 'sent');
}
function formatBodyToHTML(text: string): string {
return text
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => `<p>${line}</p>`)
.join('');
}
4. Client-Side Progress Polling
Server-Sent Events or WebSockets add complexity for a short-lived batch. Polling every 1.5 seconds is sufficient, predictable, and survives network blips without reconnection logic.
// Frontend hook
import { useState, useEffect } from 'react';
export function useBatchProgress(batchId: string | null) {
const [progress, setProgress] = useState({ sent: 0, total: 0, status: 'idle' });
useEffect(() => {
if (!batchId) return;
const interval = setInterval(async () => {
const res = await fetch(`/api/pipeline/${batchId}`);
const data = await res.json();
setProgress(data);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(interval);
}
}, 1500);
return () => clearInterval(interval);
}, [batchId]);
return progress;
}
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|---|---|
Assuming after() guarantees execution |
Serverless invocations can be terminated by the platform if memory limits are exceeded or if the runtime decides to scale down. after() is best-effort, not durable. |
Implement a lightweight recovery cron that scans for processing tasks older than 5 minutes and resets them to pending. |
| Connection pool exhaustion | Looping through 15+ emails opens a new DB connection per iteration, quickly hitting Neon/Vercel Postgres limits. | Use a connection pooler (pg.Pool or @vercel/postgres built-in pooling) and reuse the client across iterations. |
| Unvalidated LLM output | LLMs occasionally return markdown wrappers or extra fields, breaking JSON.parse(). |
Always wrap LLM responses in a Zod schema with .parse() and fallback to a retry loop with explicit JSON instructions. |
| Polling storms on high concurrency | If multiple users trigger batches simultaneously, 1.5s polling can spike API requests. | Add exponential backoff to the frontend poller after 3 consecutive identical responses, and cap max concurrent polls per session. |
| SMTP rate limiting | Gmail enforces ~500 daily sends and throttles rapid successive connections. | Add a 500ms delay between sendMail calls and track daily send counts in a separate smtp_metrics table. |
| Missing idempotency keys | If the runtime retries the invocation, the same task might be processed twice, sending duplicate emails. | Add an idempotency_key column to outreach_tasks. Check it before processing and use ON CONFLICT DO NOTHING for status updates. |
| Ignoring function timeout boundaries | Vercel Hobby/Pro functions timeout at ~60s. Processing 20 emails at 3s each exceeds this, causing silent failures. | Chunk batches into groups of 10. If more are needed, self-fanout by creating a new batch and scheduling it via after(). |
Production Bundle
Action Checklist
- Define
outreach_batchesandoutreach_taskstables withstatus,attempts, andidempotency_keycolumns - Configure
@vercel/postgresorneonclient with connection pooling enabled - Wrap LLM responses in Zod validation before SMTP dispatch
- Implement atomic
UPDATE ... RETURNINGclaim logic to prevent race conditions - Add a 500ms delay between SMTP sends to respect provider rate limits
- Set up a daily cron to reset
processingtasks older than 10 minutes topending - Monitor function execution time and chunk batches if approaching 45s
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Personal tool / <20 items per trigger | after() + Postgres state machine |
Zero infra overhead, fast feedback, fits within serverless limits | $0 additional |
| Team dashboard / 20β100 items per trigger | Chunked after() + self-fanout |
Avoids timeout boundaries while keeping deployment simple | $0β$5/mo (DB reads) |
| Enterprise / 100+ items or strict SLA | Redis + BullMQ or QStash + dedicated worker | Guaranteed delivery, horizontal scaling, dead-letter handling | $15β$50/mo + worker runtime |
| High-frequency automated jobs | Inngest or Temporal | Built-in retries, scheduling, observability, and workflow orchestration | $20β$100/mo depending on volume |
Configuration Template
// lib/pipeline.ts
import { sql } from '@vercel/postgres';
import { after } from 'next/server';
import { claimNextTask, generateAndSend, updateTaskStatus } from './db';
export async function createOutreachBatch({
jobDescription,
recipients,
}: {
jobDescription: string;
recipients: string[];
}) {
const batch = await sql`
INSERT INTO outreach_batches (job_description, total_tasks, status)
VALUES (${jobDescription}, ${recipients.length}, 'active')
RETURNING id;
`;
const batchId = batch.rows[0].id;
await sql`
INSERT INTO outreach_tasks (batch_id, recipient, status, attempts)
VALUES ${sql(recipients.map(r => [batchId, r, 'pending', 0]))}
`;
return batchId;
}
export async function scheduleBatchExecution(batchId: string) {
let task = await claimNextTask(batchId);
while (task) {
try {
const batch = await sql`SELECT job_description FROM outreach_batches WHERE id = ${batchId}`;
await generateAndSend(task, batch.rows[0].job_description);
} catch (err) {
console.error(`Task ${task.id} failed:`, err);
await updateTaskStatus(task.id, 'failed', String(err));
}
task = await claimNextTask(batchId);
}
await sql`UPDATE outreach_batches SET status = 'completed' WHERE id = ${batchId}`;
}
Quick Start Guide
- Initialize the database: Run the schema migration to create
outreach_batchesandoutreach_taskswithstatus,attempts,idempotency_key, and timestamp columns. - Configure environment variables: Set
GROQ_API_KEY,SMTP_USER,SMTP_PASS, andDATABASE_URLin your Vercel project dashboard. - Deploy the route: Add the
POSThandler toapp/api/chat/route.tsand the progress endpoint toapp/api/pipeline/[batchId]/route.ts. - Test with a small batch: Paste 3β5 email addresses and a job description into the chat. Verify the response returns a
batchId, the UI polls correctly, and emails arrive within 10β15 seconds. - Add monitoring: Log batch completion times and failure rates. If average execution exceeds 40s, implement chunking logic to split larger lists automatically.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
