TL;DR

Your client insists on SMTP. You are on Vercel. And those two things hate each other.

This post shows how to bridge them: queue emails into PostgreSQL via Next.js after(), then process the queue with a Vercel cron. The user never waits, no 3rd-party e-mail service required, and idempotency is handled at the DB level.

Context

One core functionality of this project is sending notifications: when a certain trigger fires, 15-20 users need to be notified via e-mail simultaneously. Standard stuff. Except the client explicitly ruled out 3rd-party email services (Resend, Sendgrid), and insisted on SMTP.

This is the hard constraint: SMTP is excellent for what it was designed for, but it is inherently stateful and slow - two things that serverless (stateless and short-lived) are fundamentally bad at. You can’t just use await sendEmail() in a route handler and call it a day.

The requirements:

  • Users must not be blocked (for long seconds) after submitting data
  • SMTP must be used for email delivery —> no 3rd-party delivery services
  • delivering emails doesn’t need to be instant
    • it doesn’t matter if clients receive emails delayed
    • it doesn’t matter if not all clients receive emails at the same time
  • scalability is not the primary concern

Important Note: Code examples are simplified for clarity.

Solution: The Three Pillars

  1. The Buffer: email_messages table in PostgreSQL
  2. The Producer: Next.js after() to queue rows without blocking the response and therefore the UI
  3. The Consumer: Vercel Cron job that processes the queue in batches
Producer flowchart Consumer flowchart

The Buffer

A single table tracks every email and its delivery state:

CREATE TYPE email_status AS ENUM ('queued', 'sending', 'sent', 'failed');
CREATE TABLE email_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_email TEXT NOT NULL,
subject TEXT NOT NULL,
body_html TEXT NOT NULL,
status email_status DEFAULT 'queued',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
sent_at TIMESTAMP WITH TIME ZONE
);

The Producer

Data submission.

export const submitUserRequest = async (formData: FormValue) => {
// Validate and persist business data...
// Queue notifications after the response is sent.
// `after()` keeps the serverless function alive until the callback completes,
// but the user gets their response immediately, before this runs.
after(async () => {
try {
const emailRelevantData = someHeavyWeightCalculations(formData)
await queueUserNotifications(emailRelevantData)
} catch (error) {
logger.error('Unexpected error queueing notifications', { error })
}
})
return okResponse('', StatusCodes.CREATED)
}

The key is: after(async () => doTheLongRunningSideEffect())

The user gets response before the callback finishes —> no blocking. See Next.js after() docs for lifecycle details

The Consumer

async function GET(request: NextRequest) {
try {
// Atomically claim a batch of queued emails.
// FOR UPDATE SKIP LOCKED ensures two overlapping cron runs
// never process the same row.
const result = await db.query(`
WITH next_batch AS (
SELECT id
FROM email_messages
WHERE status = 'queued'
ORDER BY created_at ASC
LIMIT 20
FOR UPDATE SKIP LOCKED
)
UPDATE email_messages
SET status = 'sending'
FROM next_batch
WHERE email_messages.id = next_batch.id
RETURNING *;
`)
const emailPayload = await createPayload(result)
await EmailService.send(emailPayload)
await db.query(
`
UPDATE email_messages
SET status = 'sent', sent_at = NOW()
WHERE id = ANY($1)
`,
[result.map((r) => r.id)],
)
return NextResponse.json({ success: true })
} catch (error) {
// Mark rows as failed so they can be inspected or retried
logger.error('Cron job failed', { error })
return NextResponse.json({ success: false }, { status: 500 })
}
}

Let’s break this down:

  1. WITH next_batch AS (...): selects the oldest queued rows and immediately claims them by setting status = 'sending'
  2. FOR UPDATE SKIP LOCKED: makes the claim idempotent —> if two cron run overlapped (very unlikely at 15-minute frequency, but possible), each will skip rows the other has already locked —> no duplicate sends.
  3. LIMIT: controls batch size. Tuned to stay within serverless timeout (see trade-offs below)
  4. RETURNING: hands back the claimed rows directly for processing without a second query

Recovering stuck rows

If the cron times out mid-batch, some rows will be stuck in ‘sending’ forever. Either a separate cron or at the top of the consumer before fetching new rows:

-- Reset rows stuck in 'sending' for more than 30 minutes
UPDATE email_messages
SET status = 'queued'
WHERE status = 'sending'
AND created_at < NOW() - INTERVAL '30 minutes';

In a production schema, an updated_at column would allow for tighter recovery windows.

Run this before the main batch fetch, so recovered rows are picked up in the same execution.

Trade-Offs

Serverless Timeout Limits

The LIMIT directly controls your timeout. At ~100ms per SMTP send (optimistic), a batch of 20 emails takes ~2 seconds. At 500ms/send (cold SMTP server, TLS overhead), that’s already 20 seconds. Know your SMTP server’s average latency and set your LIMIT accordingly. Also, leave 30-40% headroom before the function times out.

This solution handles roughly 500-2 000 emails/day comfortably at a 15-minute cron interval and LIMIT 20. If you are pushing beyond that, you’re past the ceiling of this approach and should look at a proper queue solution (BullMQ, Inngest, Trigger.dev).

SMTP error Handling

SMTP errors are not uniform. A connection timeout is retriable, a 550 User unknown is not. Retrying it wastes cycles and risks getting your IP flagged. At minimum, distinguish between:

  • Transient errors (e.g.: connection refused, timeout) —> retry via failed status and re-queue logic
  • Permanent errors (e.g.: bad address, mailbox full) —> mark failed permanently and alert

A full retry strategy is not the scope of this blogpost, but don’t skip it in production.

Latency

Worst-case delivery delay depends on queue depth:

ceil(queued_ahead / LIMIT) * cron_interval.

With 200 emails ahead of yours and LIMIT 20 at 15-minute intervals, that’s 150 minutes. If the queue stays shallow this is fine. If it can back up under load, factor that into your SLA. If near-instant delivery is required, this approach doesn’t fit —> use a proper queue with a worker process instead.

Number of queued e-mailsLIMITCron intervalLatency
202015 minmax. 15 min
2002015 minmax. 150 min
20002015 minmax 1500 min

Scalability and maintainability

This solution is obviously not scalable beyond a few thousand emails per day. Increasing LIMIT and decreasing cron frequency buys you a headroom, but you’ll hit Vercel’s function timeout ceiling before you hit serious scale. Treat this as the right tool for low-to-medium volume, not a foundation of enterprise growth.

Conclusion

Building a custom queue isn’t about reinventing the wheel, it’s about building a bridge when the standard road is closed. By combining PostgreSQL’s transactional guarantees with the lifecycle management of Next.js’s after() and a simple cron, you get a notification system that respects serverless constraints, keeps user experience smooth and stays debuggable without a 3rd-party dependency.

But keep the trade-offs in your mind: limited throughput, eventual delivery, and SMTP complexity. Know the ceiling before you build it. For this project, the constraint made it the right call.