Frontend engineer · GEN · 2025-26 · React, TypeScript, Vite, Remotion

Vidsheets and the bundle diet

Vidsheets gives video generation a spreadsheet's semantics. A row holds one video's worth of material and each cell is one element of it: script, text, audio, image, video. Edit a cell and everything downstream recalculates. A dependency graph in the client works out what is now stale and fans out one job per stale node, then a final composite step assembles the result into the video. I designed and shipped it, then spent the rest of my time at GEN on the bundle.

ONE ROW, ONE VIDEO'S MATERIALSCRIPTTEXTAUDIOIMAGEVIDEOEDIT A CELL: ONE REQUEST FOR THAT CELLSCHEDULERBFS MARKS WHAT IS STALEONE JOB PER STALE NODEMINEANOTHER TEAMRENDER QUEUE, JOB LAYER,VENDOR INTEGRATIONSSERVER PUSHES ONE CELLONE LISTENER, WHOLE GRIDFINAL COMPOSITE STILL POLLS,EVERY TWO SECONDS, PER JOB
Edit a cell, everything downstream recalculates. One job per stale node.
  • -37%

    main bundle

    GEN, before and after production builds, March and April 2026, gzip

  • -77%

    Vidsheets chunk

    GEN, same builds, gzip

moment.js out of app code and the main bundlelodash-es tree-shaking9 vendor chunks

Problem

GEN's platform generates AI video. The teams using it rarely want one video. They want the same idea many times over, each with different copy, a different product shot, a different call to action. The interface asked them to configure those one at a time.

That is the kind of problem where the interface itself is the bottleneck. No amount of render speed helps if a person has to fill in the same form once per video.

Constraints

Render jobs are slow and some of them fail. A sheet that recalculates has to survive partial failure, report honest status for every cell, and never quietly drop work someone spent an afternoon building.

The app also lives inside a Radix based design system shared with other teams, so I could not solve a layout problem by inventing a new component. Whatever I built had to be assembled from parts other people also depend on.

What I did

I took the spreadsheet metaphor literally and carried it all the way down, including the part people forget: a spreadsheet recalculates. A row holds the material for one video and each cell is one element of it. The columns are stages that depend on each other rather than a list of fields, so editing a cell marks everything downstream of it stale.

The configuration surface came to more than fourteen form types, all driven by React Hook Form with Zod schemas, so a malformed row fails at the edge of the app instead of deep inside the render pipeline.

Job state lives in TanStack Query, with Zustand holding local UI state. Editing a cell issues one request for that cell, and pasting a column groups into one debounced pass rather than a request per keystroke. When a write succeeds the scheduler walks the dependency graph, marks what is now stale, and fans out one job per stale node.

Status used to arrive by polling: every running cell mounted a poller of its own, one endpoint per job, every two seconds. It arrives now on a single Firebase Realtime Database listener for the whole grid, and the cell the server names is the cell that refetches. The final composite step still polls every two seconds per job; the change covers the cell level jobs.

When the bottleneck turned out to sit on the other side of the API, I went into the Rails app and fixed it there: an N+1 on the job association, that association eager loaded by default, a serializer flipped to opt in, a composite index, and bulk insert when cells are created. One deliberate trip across the boundary, not a claim on the backend.

For the workflows that are not tabular, I built node editors on React Flow. Rendering runs through Remotion.

Decisions

DecisionWhyWhat I rejectedWhat it cost
Go after dependencies before application codemoment.js came out of app code for date-fns and lodash moved to lodash-es, so the bundler could drop what nobody imported.Rewriting application code first.Hand tuning kept going until the numbers stopped moving, and then stopped improving them.
Colocate interdependent packages in one chunkSplitting them by name initialized two chunks in a circle and threw a temporal dead zone at runtime.Naming vendor chunks package by package, which is what shipped first and was reverted.
Replace one poller per cell with one listener for the gridEvery running cell used to mount a poller of its own against its own endpoint, every two seconds. One listener takes the push and refetches the cell the server names.Keeping a poller per job and tuning the interval.It covers the cell level jobs. The final composite step still polls every two seconds per job.

Failure modes and recovery

Failure modeWhat happensWhere it recovers
A node failsThe scheduler counts a failed node as finished, so nothing downstream of it runs. Those cells are not marked as blocked either: they keep the values they already had.The person runs that cell again.
A vendor call failsRetries live in the server side job layer. The client does not retry at all, which keeps one behaviour in one place instead of two that can disagree.The person edits the cell or runs it again.
The server reports an errorA toast, and an overlay on the cell the error belongs to.The failure names its own cell, so the fix starts where it happened.

Details that mattered

The performance program is the part I would defend hardest, and almost none of it was clever. I split routes along product boundaries, so someone loading one product stops paying to download the others. Then I went after dependencies rather than code: moment.js came out of app code entirely for date-fns, and lodash moved to lodash-es so the bundler could drop what nobody imported.

The vendor split is where I got it wrong first. Adding manualChunks broke the app at runtime with a temporal dead zone error: two chunks initialized in a circle, and whichever one lost the race read a binding before it existed. That is a known Rollup limitation rather than something I could patch, so I reverted the whole change, wrote up the investigation, and re-landed it by colocating packages that depend on each other in the same chunk instead of splitting them by name.

What shipped is nine vendor chunks, one per heavy stack: remotion, handsontable, viz, ag-grid, media, calendar, react core, data, and auth. Three of the nine, remotion, handsontable, and ag-grid, load only on the pages that render them. The rest still arrive up front; splitting them is what lets vendor code cache independently of an app deploy.

The rest was hand tuning: Vite chunking, aliases, and pre-bundling, measured after every change, until the numbers stopped moving. The main bundle came down from 1.9 MB to 1.2 MB gzip, 37%, and the Vidsheets chunk from 416 KB to 97 KB, 77%.

MAIN BUNDLE-37%1.9 MB1.2 MBVIDSHEETS CHUNK-77%416 KB97 KB
Gzipped size before and after, both rows on one scale.