Why the Temporal Bun SDK uses Bun and protobufs directly

Why the Temporal Bun SDK uses Bun and protobufs directly cover image

When I started exploring Temporal on Bun, the tempting route was to keep using Temporal Core, write a Zig bridge, and expose a native module to Bun. That made a good prototype. It also made the runtime boundary the most complicated part of the system.

The bridge had to understand polling, pending handles, worker lifecycle, build IDs, callbacks, cleanup, and FFI failures while the project was still trying to establish that its Temporal behavior was correct. At some point the question stopped being “can Zig call this ABI?” and became “do I want to debug a second worker runtime?”

The answer was no. The direction in proompteng/lab is now more direct: run the SDK on Bun, generate the Temporal protocol types, and keep the worker logic in TypeScript.

That choice is less exotic. It is much easier to inspect.

Start with the protocol Temporal already speaks

Temporal separates the application that starts a Workflow, the Temporal Service, and the Worker that runs application code. The service persists Event History and places tasks on queues. A Worker polls a queue, replays the history it receives, runs the Workflow until it reaches a wait point, and sends Commands back to the service.

The Temporal architecture walkthrough describes the loop in the same terms. A small Workflow task follows this shape:

  1. The service returns a Workflow Task with the relevant history.
  2. The Worker rebuilds Workflow state by replaying that history.
  3. Workflow code asks for an Activity, Timer, Signal, or child Workflow.
  4. The SDK turns that request into a Command.
  5. The Worker sends RespondWorkflowTaskCompleted with those Commands.
  6. The service records the resulting Events and schedules whatever should happen next.

That protocol loop is what a Bun worker has to preserve. It does not need a clever translation layer if it can represent the same messages and keep the same ordering rules.

The following is illustrative pseudocode, not a complete application call. It uses the generated names from the current package to show the protocol seam; the real SDK also wraps transport, configuration, headers, retries, and payload conversion:

import { create } from '@bufbuild/protobuf'
import { createClient } from '@connectrpc/connect'
 
import { PollWorkflowTaskQueueRequestSchema } from './proto/temporal/api/workflowservice/v1/request_response_pb'
import { WorkflowService } from './proto/temporal/api/workflowservice/v1/service_pb'
 
// Illustrative pseudocode; the SDK supplies this transport configuration.
declare const transport: Parameters<
  typeof createClient<typeof WorkflowService>
>[1]
 
const workflowService = createClient(WorkflowService, transport)
 
const task = await workflowService.pollWorkflowTaskQueue(
  create(PollWorkflowTaskQueueRequestSchema, {
    namespace: 'default',
    taskQueue: { name: 'hello-bun' },
    identity: 'example-worker',
  }),
)

The point of this illustrative snippet is not to make application code call the polling RPC itself. It is to show what “direct protobufs” means: the generated message and the generated WorkflowService descriptor are ordinary TypeScript values. The worker can be tested against them, logged around them, and reviewed without first decoding an opaque native callback protocol.

Generated code is a seam, not a magic trick

The Temporal API repository is the source of the platform's gRPC and protobuf definitions. In the current lab tree, update-temporal-protos.ts is the source-controlled regeneration entry point. It resolves a Temporal API tag, refreshes proto/temporal, writes the version marker, runs Buf with buf.temporal.gen.yaml, and emits generated TypeScript under packages/temporal-bun-sdk/src/proto.

The generated WorkflowService descriptor is a concrete example of that output; the same tree contains the OperatorService descriptor. The article's proto/temporal and buf.temporal.gen.yaml names are therefore still real inputs in the current repository, while src/proto is the generated output. Keeping the update script and generated artifacts in the repository makes a protocol change a deliberate, reviewable diff.

Protocol Buffers give the project a typed, language-neutral representation of messages. Buf's generation workflow also makes the generator and output location explicit. That matters in a worker because the useful failures are visible at a familiar boundary: a field has the wrong shape, a service method is missing, or a generated artifact no longer matches the server API.

The project uses Buf's JavaScript/TypeScript runtime, @bufbuild/protobuf, alongside Connect transport. Protobuf-ES supplies the generated message schemas and binary/JSON operations; it does not pretend to implement Temporal's Workflow semantics. That division is healthy. Serialization stays in the generated layer, while replay, command ordering, cancellation, and lifecycle behavior remain responsibilities of the worker runtime.

The evidence path is just as explicit. The replay harness guide documents captured histories, fixture shape, and manifest verification. The replay CI gate is the command path that feeds a history directory to the SDK's replay executor and can make missing histories fail when the gate is required. These are useful links to keep beside the generated code: one describes the corpus, and the other describes how CI invokes replay.

Commands are where Workflow code becomes durable

A Workflow should read like a description of durable work, not like a regular function that happens to run forever. In the Bun SDK, a Workflow can express an Activity intent and let the runtime materialize the corresponding Temporal command. The next block is also illustrative pseudocode, adapted from the current project documentation. It focuses on the workflow-to-command shape; worker registration and runtime wiring are omitted:

// Illustrative pseudocode; worker registration and runtime wiring are omitted.
import { Effect } from 'effect'
import * as Schema from 'effect/Schema'
 
import { defineWorkflow } from '@proompteng/temporal-bun-sdk/workflow'
 
export const helloWorkflow = defineWorkflow(
  'helloWorkflow',
  Schema.Array(Schema.String),
  ({ input, activities, determinism }) =>
    Effect.gen(function* () {
      const [rawName] = input
      const name =
        typeof rawName === 'string' && rawName.length > 0 ? rawName : 'Temporal'
 
      yield* activities.schedule('sleep', [10])
      yield* activities.schedule('echo', [{ message: `Hello, ${name}!` }])
 
      return `Greeting queued at ${new Date(determinism.now()).toISOString()}`
    }),
)

activities.schedule does not perform the external work in the Workflow's process. It records an intent that the worker turns into a ScheduleActivityTask Command. Temporal can then persist the resulting Events and wake the Workflow when the Activity completes. determinism.now() is likewise a runtime-controlled clock, so a replay sees the same logical value rather than an arbitrary wall-clock read.

This is why generated messages matter. A nice Workflow API is useful, but it is not the durable contract by itself. The runtime has to connect that API to the exact Temporal command and history model.

Why the Zig bridge stopped being the right center of gravity

The first native design put Temporal Core behind Zig and made Bun consume a translated lifecycle. That approach had real value: it forced the project to name the hard pieces instead of hiding them behind a package import. It also meant that every correctness question crossed an additional boundary.

The official Temporal TypeScript SDK README is candid about why a normal Node worker cannot simply be switched to Bun. Its worker-level features rely on Node-API native modules, worker_threads, vm, AsyncLocalStorage, and async_hooks. The official SDK is supported on Node, and the README explicitly warns that alternative runtimes are not officially supported for Workers.

That leaves two honest options: run the official worker on its supported runtime, or build a worker whose runtime boundary is Bun from the beginning. The package in this article is the second option. It is not a compatibility flag for @temporalio/worker, and it is not an official Temporal SDK.

What Bun changes—and what it does not

Bun gives the package one environment for TypeScript execution, dependency installation, worker entry points, replay commands, and local tooling. The SDK's own surface includes a Bun worker, a client, test helpers, replay tooling, and the temporal-bun CLI. The practical benefit is that the pieces share one Bun-first environment.

It does not make Workflow code unconstrained. A Workflow still has to be deterministic. Network calls and other side effects belong in Activities. Timers, updates, signals, retries, heartbeats, cancellation, sticky execution, and graceful shutdown still need behavior that can survive a replay and a process restart.

The Bun quickstart is useful for understanding the runtime and package-manager pieces. It is not a guarantee about Temporal correctness, and a faster local command is not a substitute for a replay fixture.

The evidence has to stay close to the code

The lab package includes production-readiness checks, replay fixtures, integration tests, load checks, package-boundary checks, and generated readiness artifacts. Those artifacts are valuable because they turn a broad claim such as “Bun support” into questions that can be rerun:

  • Can a real history be replayed with the current Workflow code?
  • Do the generated commands match the behavior expected by the server?
  • Do Activity cancellation, retries, and heartbeats complete through the same lifecycle as the happy path?
  • Does the published package avoid the native bridge and official Node worker dependency path it claims to avoid?

Generated protobufs make those questions more concrete. They do not answer them on their own, but they keep the answer from being hidden in an FFI layer that few tests exercise.

The migration lesson

Moving from a native bridge to a direct Bun runtime is an architectural migration, not a syntax change. The bridge owned polling, pending handles, callbacks, lifecycle, and FFI error paths. In the Bun-first design, generated protobufs define the wire seam while TypeScript owns worker lifecycle, replay, command ordering, and determinism.

That changes what a migration plan needs to prove. Keep a replay corpus, run integration checks for Activity cancellation, retries, and heartbeats, and verify that the packaged worker has no native bridge or official Node worker dependency path. The replay fixture guide and replay CI gate make those checks concrete; the generated service descriptor and update script make protocol updates reviewable.

The original Zig experiment was useful because it showed where the runtime boundary lived. The lasting architecture is smaller: Bun runs the process, generated protobufs describe Temporal's messages, and the TypeScript worker runtime owns the durable-execution rules. A future migration should preserve that separation first, then optimize the API around it.