●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Building a Production-Grade Gemini API Backend with NestJS — DI, Filters, and Guards
A practical pattern for wrapping the Gemini API in a NestJS backend. Covers DI-based service design, SSE streaming, exception filters, guards, multi-model Flash-to-Pro fallback, exponential-backoff retries, and measured latency/token metrics.
If you spin up a Gemini API endpoint with Express or a thin Hono handler, the first few weeks feel great. The pain shows up later — when the surrounding concerns (auth, rate limiting, multi-model fallbacks, structured error messages) start to pile up inside controllers. Across both my own indie projects and the production work I help with, this is usually the moment I switch the codebase to NestJS. Its DI container, decorators, exception filters, and guards happen to map almost one-to-one onto the messy outside layers of an AI backend.
This article shares the layout I keep coming back to when wiring Gemini into NestJS — kept short on purpose, with a deliberate note on why each piece is there rather than only the how.
Why NestJS suits the Gemini API specifically
NestJS borrows its design language from Angular and forces a Module / Controller / Provider split through DI. That structure pays off precisely when your service has lots of external moving parts.
There are three concrete reasons I reach for it. First, registering the GoogleGenerativeAI client as a Provider lets you swap it for a mock in tests without touching controllers. Second, NestJS exception filters with @Catch() give you one place to translate Gemini's varied errors — 429 quota, 503 overloaded, 400 safety blocks — into a stable shape for the client. Third, Guards keep auth and per-user rate limits out of your handler bodies entirely. Doing the same in Express tends to drift into middleware-ordering pain.
Project layout: a single GeminiModule
The minimal version is a GeminiModule that exposes a GeminiService wrapping the SDK client.
// src/gemini/gemini.module.tsimport { Module } from '@nestjs/common';import { GoogleGenerativeAI } from '@google/generative-ai';import { GeminiService } from './gemini.service';import { GeminiController } from './gemini.controller';@Module({ controllers: [GeminiController], providers: [ { provide: 'GENAI_CLIENT', useFactory: () => new GoogleGenerativeAI(process.env.GEMINI_API_KEY!), }, GeminiService, ], exports: [GeminiService],})export class GeminiModule {}
Note that the SDK client itself is a Provider. In tests you replace useFactory with a mock and never touch the network. If you instead new GoogleGenerativeAI(...) directly inside a controller, that escape hatch disappears.
The service should own one job: pick a model, format the prompt, normalize the response. Splitting one-shot and streaming into separate methods keeps the SSE controller below clean.
// src/gemini/gemini.service.tsimport { Inject, Injectable } from '@nestjs/common';import { GoogleGenerativeAI } from '@google/generative-ai';@Injectable()export class GeminiService { constructor( @Inject('GENAI_CLIENT') private readonly client: GoogleGenerativeAI, ) {} async generate(prompt: string, model = 'gemini-2.5-flash') { const m = this.client.getGenerativeModel({ model }); const res = await m.generateContent(prompt); return res.response.text(); } async *stream(prompt: string, model = 'gemini-2.5-flash') { const m = this.client.getGenerativeModel({ model }); const res = await m.generateContentStream(prompt); for await (const chunk of res.stream) { yield chunk.text(); } }}
Returning an async generator from stream() keeps the transport layer free — you can pipe it into SSE, WebSockets, or a worker queue later without rewriting this code.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Confine Flash-to-Pro fallback inside the Service, switching only on overload
✦Centralize exponential-backoff + jitter retries in an Interceptor, leaving controllers and services clean
✦Measured over 12k requests: p50/p95 latency, token usage, and the Pro fallback rate as raw data
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
NestJS exposes Server-Sent Events through the @Sse() decorator. By returning an Observable, you get automatic teardown when the client disconnects — no manual AbortController plumbing needed.
// src/gemini/gemini.controller.tsimport { Body, Controller, Post, Sse, MessageEvent } from '@nestjs/common';import { Observable } from 'rxjs';import { GeminiService } from './gemini.service';@Controller('gemini')export class GeminiController { constructor(private readonly gemini: GeminiService) {} @Sse('stream') stream(@Body() body: { prompt: string }): Observable<MessageEvent> { return new Observable((subscriber) => { (async () => { try { for await (const text of this.gemini.stream(body.prompt)) { subscriber.next({ data: { text } }); } subscriber.complete(); } catch (err) { subscriber.error(err); } })(); }); }}
One thing worth knowing: calling subscriber.error() does not route through your exception filter. SSE treats errors as a stream-end event. If you need the client to react to mid-stream Gemini failures, send a typed payload like subscriber.next({ data: { error: 'rate_limited' } }) and let the front-end decide what to do.
A single exception filter for Gemini errors
Gemini's failure modes — 429 quota, 503 overloaded, 400 safety — all want different client-side handling. Doing this with try/catch in every controller adds noise. Centralize it in a @Catch() filter.
Wire it up globally with app.useGlobalFilters(new GeminiExceptionFilter()) in bootstrap(). Normalizing Gemini's errors into a stable shape makes both retry logic and front-end UX dramatically easier — and hiding raw detail outside development prevents leaking internal traces.
Guards keep auth and rate limits out of controllers
For a public-facing wrapper, you don't want auth code mixed into business logic. A Guard does the trick:
// src/gemini/api-key.guard.tsimport { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';@Injectable()export class ApiKeyGuard implements CanActivate { canActivate(ctx: ExecutionContext): boolean { const req = ctx.switchToHttp().getRequest(); const key = req.headers['x-api-key']; if (!key || key !== process.env.PUBLIC_API_KEY) { throw new UnauthorizedException('invalid api key'); } return true; }}
For rate limits, layer on @nestjs/throttler. If you need per-user (rather than per-IP) throttling, subclass ThrottlerGuard and override getTracker() to return req.headers['x-api-key']. Putting a soft limit one layer in front of Gemini's own quota stops a single noisy client from torching your billing or cascading a 429 storm into your other endpoints.
Keep multi-model fallback inside the Service
The single change that helped most in production was falling back from gemini-2.5-flash to gemini-2.5-pro when the former returned a 503 (overloaded). The important part is keeping that decision insideGeminiService rather than in the controller. The moment a controller knows which model to pick, every model swap turns into a hunt across call sites.
// src/gemini/gemini.service.ts (excerpt)private readonly fallbackOrder = ['gemini-2.5-flash', 'gemini-2.5-pro'];async generateWithFallback(prompt: string): Promise<string> { let lastError: unknown; for (const model of this.fallbackOrder) { try { const m = this.client.getGenerativeModel({ model }); const res = await m.generateContent(prompt); return res.response.text(); } catch (err) { const msg = String((err as Error)?.message ?? err); // Only overload / quota moves to the next model; safety blocks re-throw. if (msg.includes('503') || msg.includes('RESOURCE_EXHAUSTED')) { lastError = err; continue; } throw err; } } throw lastError;}
The rule I settled on is "don't fall back on everything." A safety block (400) is caused by the input itself, so retrying it on another model produces the same result and just doubles your cost and latency. Only overload and quota errors advance to the next candidate; everything else flows straight to the exception filter. The side effect — higher latency and unit cost whenever a request lands on Pro — is something you want the metrics below to make visible.
Centralize exponential-backoff retries in an Interceptor
A 503 usually clears within a few hundred milliseconds, so a single immediate retry noticeably lifts your perceived success rate. But writing retry loops into each method tangles them with the fallback code. Pulling it into a NestJS Interceptor lets you wrap retries over everything without touching the controller or the service.
Jitter is non-negotiable: without it, many requests that hit a 503 at the same instant all retry on the same schedule and hammer the API again in unison — a retry "thundering herd." The numbers can stay modest. In my own operation, starting at 250ms and capping at 3 attempts absorbed most transient overload without meaningfully growing perceived wait time. Non-retriable errors are re-thrown via throwError and left to the exception filter to normalize.
Minimal observability: measure latency and token usage
The line between "it works" and "it's operable" is almost always the moment you add measurement. Before reaching for a heavy monitoring stack, an Interceptor that records elapsed time and pulls token counts from Gemini's usageMetadata already gives you something to reason about.
Watching this log for about a week tells you which endpoints eat tokens and how often you fall back to Pro. Here are the numbers I measured on my own wrapper API over one week (about 12,000 requests). They reflect my configuration and prompt lengths, but they gave me a concrete basis for model selection and fallback policy.
Metric
gemini-2.5-flash
gemini-2.5-pro (on fallback)
p50 latency
~0.9s
~2.4s
p95 latency
~2.1s
~5.3s
Avg output tokens / request
~320
~340
503 rate during the window
~0.8%
—
Share that fell back to Pro
~0.6% of all requests
What this told me is that fallback fired on under 1% of traffic, so the volume drifting onto the pricier Pro stayed small. The p95 spikes, meanwhile, were driven mostly by prompt length rather than retries — trimming input tokens did more for perceived speed than anything else. Without measurement, I'd almost certainly have optimized the wrong thing. When you're ready to grow this into real observability, Production-Grade Observability for the Gemini API with Langfuse drops in as a direct replacement for this Interceptor.
Three things that bite people in production
The setup above is functionally complete, but here are three traps I keep seeing right before launch.
The first is buffered streaming. On Cloud Run, Fargate, or anything fronted by Nginx/ALB, SSE often appears to "freeze" for several seconds. The fix is usually X-Accel-Buffering: no on the response, or an LB-level setting that allows streaming. If your first chunk takes 3+ seconds to appear, suspect this before suspecting Gemini.
The second is forgetting ConfigModule.forRoot({ isGlobal: true }) in AppModule, which leaves process.env.* references scattered everywhere. Going through ConfigService from day one means swapping in Vault or Secret Manager later is a one-file diff instead of a hunt.
The third is unit tests that talk to the real API because GoogleGenerativeAI was never mocked. CI runs burn quota and money. The whole reason the SDK client is a Provider in the first place is so your test module can do { provide: 'GENAI_CLIENT', useValue: mock } and stay offline.
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.