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
- The Buffer:
email_messagestable in PostgreSQL - The Producer: Next.js
after()to queue rows without blocking the response and therefore the UI - The Consumer: Vercel Cron job that processes the queue in batches
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:
WITH next_batch AS (...): selects the oldestqueuedrows and immediately claims them by settingstatus = 'sending'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.LIMIT: controls batch size. Tuned to stay within serverless timeout (see trade-offs below)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 minutesUPDATE email_messagesSET 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
failedstatus and re-queue logic - Permanent errors (e.g.: bad address, mailbox full) —> mark
failedpermanently 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-mails | LIMIT | Cron interval | Latency |
|---|---|---|---|
| 20 | 20 | 15 min | max. 15 min |
| 200 | 20 | 15 min | max. 150 min |
| 2000 | 20 | 15 min | max 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.