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:
| Dependency | Size | When Actually Needed |
|---|---|---|
| react-pdf (+ yoga-layout WASM + fontkit) | ~1.5 MB | "Download PDF" button click |
| Clerk auth SDK | ~400 KB | Never (on public share pages) |
| Sentry Session Replay | ~100 KB | Background 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:
- The image is uploaded to Cloudflare R2 (permanent storage)
- A record is created in the
imagesorcolorImagestable - The product catalog links update automatically
- 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.