Skip to content

Make await/async part of render computation graph (validation/execution separation) #163

@SecurityQQ

Description

@SecurityQQ

Problem

Currently, await Speech() and other async element resolution happens eagerly during validation, not as part of the render computation graph. This causes several issues:

In the render service (render/)

When user code is submitted to /api/render, the validation phase (route.ts:64) calls tsxToElement(code) which fully executes the user's code including all await expressions:

// Validation phase (route.ts:64)
element = await tsxToElement(code, { gateway: varg, providerKeys });

If user code contains:

const { audio, segments } = await Speech({ 
  children: ["s1", "s2", "s3"] 
});

The Speech() API call happens during validation, before the job is queued. This means:

  1. Costs money for validation — ElevenLabs TTS costs $0.20-0.50 per call. Failed validation = wasted API calls.
  2. Validation takes 5-30 seconds instead of milliseconds — blocks the HTTP request thread
  3. No backend context in validationResolveContext is only provided in the worker. Before alpha76, this caused sliceAudio to fail (no ffmpeg in Docker). After alpha76, segments work but the pattern is wrong.
  4. Duplicate execution risk — if validation runs the full pipeline AND the worker runs it again, assets get generated twice (though cache should prevent this if working correctly)

The deeper architectural issue

await in user code should be lazy — a declarative description of "this element depends on async work" that gets executed as part of the render DAG, not eagerly on first encounter.

Current behavior (eager)

const img = await Image({ prompt: "..." });  // ← API call happens HERE
const vid = Video({ prompt: { images: [img] } });

export default <Render><Clip>{vid}</Clip></Render>;

When the line await Image(...) is encountered, it immediately:

  1. Calls the AI SDK's experimental_generateImage()
  2. Waits for the fal.ai API response
  3. Returns a ResolvedElement with the image bytes/URL

This is imperative, not declarative.

Desired behavior (lazy)

const img = Image({ prompt: "..." });  // ← returns a VargElement (lazy promise)
const vid = Video({ prompt: { images: [img] } });

export default <Render><Clip>{vid}</Clip></Render>;

The Image() call returns a lazy thenable immediately. The actual API call happens when:

  • The render pipeline processes the element tree
  • All dependencies are topologically sorted
  • Each element is resolved in dependency order

Benefits of lazy evaluation

  1. Fast validation — parsing + static analysis only, no API calls
  2. Parallelization — independent elements can generate concurrently
  3. Caching at the graph level — cache keys computed before execution
  4. Conditional execution — elements in unused branches never execute
  5. Retry/resume — failed nodes can retry without re-executing the whole tree
  6. Preview mode — show placeholders without running generators

Prior art

  • React Server Components — async components resolve during render, not at definition
  • Temporal workflows — async activities are declarative, executed by the orchestrator
  • Airflow/Dagster — task DAGs execute lazily based on dependencies
  • Remotion — frame rendering is lazy, computed per-frame on demand

Proposed solution

Phase 1: Separate validation from execution (near-term fix)

Render service changes:

  1. route.ts validation phase should NOT call await tsxToElement(...). Instead:

    • Parse the code with Sucrase (syntax check)
    • Extract the export default without executing it
    • Validate it's a valid VargElement structure (static analysis)
    • Return 400 if invalid, otherwise queue immediately
  2. All actual execution (including await Speech()) happens in the worker with full ResolveContext

This is a minimal fix that solves the cloud render blocker without architectural changes.

Phase 2: Make await optional (medium-term)

Elements should work without await in user code:

// Both should work identically
const img1 = await Image({ prompt: "..." });
const img2 = Image({ prompt: "..." });

export default <Render><Clip>{img1}</Clip></Render>;

Implementation:

  • resolveLazy() already handles async components (functions returning Promises)
  • Extend it to detect VargElement & PromiseLike<ResolvedElement> and resolve them lazily
  • User code can omit await — the render pipeline awaits for them

Phase 3: Full computation graph (long-term)

Build a proper DAG:

  1. Parse phase — extract all element definitions, detect dependencies (e.g., images: [img] creates an edge Image → Video)
  2. Plan phase — topological sort, detect cycles, compute cache keys
  3. Execute phase — resolve elements in dependency order, maximizing parallelism
  4. Compose phase — editly stitching, same as today

Libraries to explore:

  • task-graph-runner — simple DAG executor
  • bull-board — already used for job queue, could extend for task DAGs
  • effect — already used in gateway, has built-in dependency tracking

Related issues

Files to modify

Near-term (Phase 1):

  • render/src/api/render/route.ts — make validation static
  • render/src/api/render/tsx-eval.ts — add a parse-only mode

Medium-term (Phase 2):

  • sdk/src/react/renderers/resolve-lazy.ts — handle thenable VargElements
  • sdk/src/react/elements.ts — document that await is optional

Long-term (Phase 3):

  • New sdk/src/react/graph/ module — DAG builder, executor
  • sdk/src/react/render.ts — use graph executor instead of direct resolution

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions