GrahAI Systems logo
GrahAI Systems
Back to BlogAI Architecture

How We Scaled an AI SaaS From Zero to Production

GrahAI EngineeringJune 18, 202611 min read

How We Scaled an AI SaaS From Zero to Production

Most AI products die in the gap between the demo and the second paying customer. The demo runs on someone's laptop with a warm API key and a happy path. Production has cold starts, retrying webhooks, half-finished jobs, and a billing dashboard that only goes up. This is what we actually had to engineer to get our products on Next.js + Firebase + Vercel from zero to something that runs unattended for real users in India and the World.

The serverless trap: fire-and-forget does not work

The single most expensive lesson we learned was simple: on serverless, a request that returns is a request that is done. Vercel functions freeze or terminate the execution context the moment you send the response. So the classic pattern of kicking off a long task and returning early quietly drops the task.

We had report generation that looked like this:

```js // BROKEN on serverless export async function POST(req) { const job = await createJob(req); generateReport(job); // not awaited return Response.json({ status: 'pending' }); } ```

In local dev this works perfectly. In production a meaningful fraction of reports stayed stuck at `status: 'pending'` forever, because the function returned and the runtime froze before `generateReport` finished its network calls. Users paid and got nothing.

The fix is to tell the platform you still have work to do. Next.js exposes `after()` exactly for this:

```js import { after } from 'next/server';

export async function POST(req) { const job = await createJob(req); after(async () => { await generateReport(job); }); return Response.json({ status: 'pending' }); } ```

`after()` runs the callback after the response is sent but while the function is kept alive by the platform. The rule we now enforce in review: never start an async side effect on Vercel without `after()` or a queue. If a reviewer sees a bare un-awaited promise in a route handler, it does not merge.

Idempotent webhooks or you will double-charge feelings

Payment providers retry. Razorpay will deliver the same `payment.captured` event more than once, especially under load or if your handler is slow to 200. If your webhook is not idempotent you will grant credits twice, email the customer twice, and double-count revenue in analytics.

Our rule: derive a deterministic document id from the provider's own id and write with create-if-absent semantics.

```js // Use the payment id as the doc id, not an auto id const ref = db.collection('payment_logs').doc(`rzp_${payment.id}`); await db.runTransaction(async (tx) => { const snap = await tx.get(ref); if (snap.exists) return; // already processed, no-op tx.set(ref, { ...payload, processedAt: Date.now() }); // grant entitlement inside the same transaction }); ```

We learned this the hard way: our analytics dashboards were inflating revenue because retried webhooks each wrote a fresh auto-id document. Switching to `rzp_` as the doc id made the write naturally idempotent and the numbers became honest overnight.

Cold starts are a product problem, not just an infra one

A cold serverless function plus a cold model call plus a cold database connection is a multi-second wait that users read as 'broken.' We attacked it from three sides.

First, keep the hot path thin. The route handler that the user waits on should do the minimum: validate, write a pending record, return. The heavy LLM work moves to `after()` or a job, and the UI polls or subscribes.

Second, stream. For chat and any user-facing generation, we stream tokens so the time-to-first-token is what the user feels, not the time-to-full-response. A 6-second completion that starts rendering at 600ms feels fast.

Third, reuse connections. We initialize the Firebase Admin SDK and any clients at module scope so warm invocations reuse them rather than reconnecting on every request.

Background jobs on a platform with no servers

Serverless has no long-running worker, so 'background job' means one of three things for us: `after()` for short tail work that fits in the function lifetime; a cron-triggered route for scheduled work (daily quality audits, renewal reminders, dormant win-back email); and a self-healing read path for anything that might have been dropped.

That last one is underrated. Our report viewer checks: if a report is `pending` and was created more than N seconds ago and the payment is captured, it re-triggers generation itself. The viewer becomes the safety net for any job the write path lost. Defense in depth beats hoping the happy path always fires.

Observability you will actually look at

You cannot fix what you cannot see, but you also will not read a firehose. We log structured events at the boundaries that matter: payment received, entitlement granted, generation started, generation completed, generation failed with the model and token counts. A daily cron summarizes failures and emails the founder a digest. The goal is not a Grafana wall nobody opens; it is one email that tells you if yesterday was healthy.

Cost: measure per request or fly blind

AI cost does not show up as a line item until the invoice arrives, by which point it is a surprise. We attach token usage to every generation event and roll it up to cost-per-request and cost-per-paying-user. Once you can see that a single SKU is eating margin, you can route it to a cheaper model, cache it, or trim its context. Without per-request attribution, 'our AI bill is high' is a feeling, not a plan.

What actually scaled us

None of this is exotic. The pattern is: keep the synchronous path tiny, make every side effect idempotent, never trust a fire-and-forget on serverless, give dropped work a self-healing path, and put a number on every request. Demos optimize for the happy path; production is the sum of all the unhappy ones.

If you are planning a production AI build and want it to survive contact with real users, GrahAI Systems can help — reach us at support@grahai.com.

Project Consultation

Interested in deploying custom AI systems?

We custom-engineer AI agents, databases, and LLM integrations that replace tedious administrative tasks.

Get In Touch