Vache prompts. Claude codes.How it works

Shipping AI Image Generation While Cutting 2MB from the Bundle

·7 min read·by Vache Sarkissian
Updated June 3, 2026
·
Reviewed March 29, 2026
next.jsconvexgeminiperformanceai
📚Top of Funnel

Written by Claude (Opus 4.6) Vache prompted, reviewed, and published. The data and benchmarks are real; the prose is AI-generated.

Linesheet is a SaaS platform for wholesale fashion brands. Last week I shipped a full AI image generation pipeline — multi-view product shots from text prompts, iterative refinement, two quality tiers — while simultaneously cutting ~2MB from the initial page bundle. And along the way, I found a Convex caching bug that had been silently re-running queries on every subscription tick.

The Bundle Problem

Before the optimization, every page load pulled in three heavy dependencies that most users never needed on first render:

DependencySizeWhen Actually Needed
react-pdf (+ yoga-layout WASM + fontkit)~1.5 MB"Download PDF" button click
Clerk auth SDK~400 KBNever (on public share pages)
Sentry Session Replay~100 KBBackground recording

That's ~2MB of JavaScript downloaded before the user sees a single product image. On a /share/[token] page — where a buyer is viewing a linesheet link with no auth required — the browser was loading Clerk's entire authentication SDK for nothing.

Fix 1: Lazy-Load react-pdf

The PDF generation hook was statically importing @react-pdf/renderer at module scope. Every page that could potentially generate a PDF loaded the entire renderer, yoga-layout WASM binary, and font toolkit at initial load.

The fix is a dynamic import() inside the generation function:

// Before: 1.5MB loaded on every page render
import { pdf } from "@react-pdf/renderer";
import { FancyTemplate } from "../components/pdf/FancyTemplate";
 
// After: 1.5MB loaded only when user clicks "Download PDF"
const [{ pdf }, { FancyTemplate }] = await Promise.all([
  import("@react-pdf/renderer"),
  import("../components/pdf/FancyTemplate"),
]);

The user clicks "Download PDF", sees a brief spinner while the chunk loads (~300ms on a reasonable connection), and gets their file. On subsequent clicks it's instant — the chunk is cached.

Fix 2: Route-Based Clerk Splitting

Next.js App Router has route groups — parenthesized directories that affect layout nesting without changing the URL path. I used this to separate authenticated from public routes:

src/app/
├── layout.tsx              # Root: Convex + Analytics only
├── (protected)/layout.tsx  # Clerk + ConvexProviderWithClerk
├── (auth)/layout.tsx       # Clerk only (sign-in/up pages)
└── share/layout.tsx        # ConvexPublicProvider (no auth)

The share layout uses a minimal Convex provider with no Clerk dependency:

import { ConvexProvider, ConvexReactClient } from "convex/react";
 
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export function ConvexPublicProvider({ children }) {
  return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}

Buyers viewing a shared linesheet never download Clerk. The ConvexProvider still works for read-only database queries — it just skips the auth token handshake.

Fix 3: Lazy Sentry Replay

Sentry's Session Replay integration records user interactions for debugging. It doesn't need to be in the initial bundle — it can load asynchronously after the page renders:

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  replaysSessionSampleRate: 0.1,
});
 
// Load replay from CDN at runtime, not bundled
Sentry.lazyLoadIntegration("replayIntegration").then((replay) => {
  Sentry.addIntegration(replay({ maskAllText: true, blockAllMedia: true }));
});

Sentry core (~20KB) still loads immediately for error tracking. The ~100KB replay module streams in after.

The Caching Bug: Query Purity

While profiling, I noticed the analytics dashboard was re-fetching data on every Convex subscription tick — even when nothing had changed. The culprit: new Date() inside a query handler.

Convex queries must be pure — same inputs, same outputs. The runtime uses this guarantee for reactive caching: if the database rows a query depends on haven't changed, skip re-execution. But new Date() produces a different value every millisecond, so the query was never cache-eligible.

// Breaks caching — runs on every subscription tick
export const getInventoryAlerts = query({
  handler: async (ctx) => {
    const today = new Date().toISOString().split("T")[0]; // impure!
    // ...
  }
});
 
// Fixed — date passed as argument, query is pure
export const getInventoryAlerts = query({
  args: { todayDate: v.optional(v.string()) },
  handler: async (ctx, args) => {
    const today = args.todayDate ?? new Date().toISOString().split("T")[0];
    // ...
  }
});

The client passes todayDate once. The query only re-runs when actual data changes, not on every tick. This is documented in Convex's guidelines but easy to miss — there's no error, no warning, just unnecessary work.

AI Image Generation: The Architecture

With the bundle cleaned up, I had room to add something heavy: a full AI image generation pipeline powered by Gemini.

The workflow: upload a source product photo, select views (Front/Side/Back), choose framing and color, and the system generates styled product shots. Results go through an approval flow before being linked into the product catalog.

The Pipeline

User uploads source image
    ↓ Convex mutation (createJob)
Stored in Convex _storage, job scheduled
    ↓ Convex action (executeGeneration)
Gemini API called with structured prompt
    ↓ Results stored in _storage
User reviews in gallery (approve/reject/refine)
    ↓ Convex action (uploadAndLink)
Approved images uploaded to R2 + linked to product

Each step is a separate Convex function. Mutations handle state transitions (create job, approve result). Actions handle side effects (call Gemini API, upload to R2). The scheduler connects them — ctx.scheduler.runAfter(0, ...) triggers the next step without blocking.

Two Quality Tiers

const [modelName, setModelName] = useState("gemini-2.5-flash-image");

Flash (gemini-2.5-flash-image): Fast generation, fixed resolution. Good for iteration.

Pro (gemini-3-pro-image-preview): Higher quality with selectable resolution (1K/2K/4K). Used for final catalog shots.

Both run through the same pipeline. The model name is stored on the job so refinement requests default to the same tier.

Parallel View Generation

When a user selects multiple views, they generate in parallel:

const tasks = request.views.map((view) =>
  this.generateSingleView(request, view, modelName)
);
const results = await Promise.allSettled(tasks);

Promise.allSettled means a failed Side view doesn't block the Front and Back results. The job gets marked "partial" if some views fail, "completed" if all succeed.

The Prompt Engineering

The system instruction is opinionated. Fashion product photography has specific requirements that generic image generation gets wrong — tucked shirts, busy backgrounds, inconsistent models. The prompt enforces:

  • Solid studio background (#F0E6DD warm khaki — no gradients, no props)
  • The Untucked Rule — tops must hang loosely over bottoms, waistband occluded
  • Identity Lock — consistent model appearance across all generated views
  • Framing control — Full Body, Top Half, or Bottom Half with specific instructions per type

The negative prompt blocks common failure modes: messy edges, extra limbs, distorted fabric, bare feet, busy background, tucked in, half-tuck, french-tuck, belt visible.

Iterative Refinement

If a result is close but not right, the user can refine instead of regenerating from scratch. The refinement action receives both the current result image and the original source photo:

const result = await provider.refine({
  imageBase64,           // current result to improve
  originalSourceBase64,  // anchor to the original garment
  view,
  prompt: refinementPrompt,
});

Passing the original source prevents drift — without it, iterative refinement tends to wander further from the actual product with each pass.

Approval + Storage Flow

Generated images sit in Convex's temporary _storage with a 24-hour expiration. When a user approves a result:

  1. The image is uploaded to Cloudflare R2 (permanent storage)
  2. A record is created in the images or colorImages table
  3. The product catalog links update automatically
  4. The temporary storage reference can be cleaned up

Rejected results simply expire. No manual cleanup needed.

What I Learned

Lazy-load at the right layer. Dynamic imports inside event handlers (not at route level) give the best UX — the user sees a brief spinner on the specific action, not a slower initial page load. Route-level splitting is for auth boundaries, not feature boundaries.

Query purity violations are silent. Convex won't warn you about new Date() in a query. The only symptom is excess re-renders and unnecessary database reads. If your Convex dashboard shows a query running far more often than expected, check for impure inputs.

Approval workflows need two storage tiers. Temporary storage (Convex _storage with TTL) for review, permanent storage (R2) after approval. Without this, you either pay for storage on rejected images forever or risk losing approved ones.

Bundle optimization and feature development aren't sequential. I shipped both in the same sprint because the optimizations freed up budget for the AI pipeline's SDK. Cutting 2MB of unnecessary code made room for the Gemini client and its dependencies without regressing load times.

Further Reading

About the Author

Vache Sarkissian

Building research infrastructure and products at the intersection of knowledge systems and machine learning. Creator of Linesheet Pro, vault-search, and the vachsark learning engine.

View Full Bio →
© 2026 Vache Sarkissian·Built with Claude Code
vachsark.com