Restate - Orchestrating Long-running Third-party API Jobs with Go or Bun

Introduction⌗
Calling a third-party API looks like a simple HTTP request. Once that request takes minutes or hours, however, the real questions change. Where does execution resume after a process restart? Could a timeout create the same remote job twice? What happens if the callback arrives early? Could a retry charge us twice? How do we tell whether a job is running, waiting, retrying, or stuck?
A traditional queue moves work into the background, but we often still have to assemble a state table, retry counters, delayed jobs, distributed locks, and compensation logic ourselves. Restate takes another approach: business processes remain ordinary Go or TypeScript functions, while Restate persists their execution log and replays confirmed results after a failure.
This article focuses on one practical question: how should we schedule jobs that wait on slow AI, video generation, payment, logistics, or other third-party APIs, and when should we choose Go or TypeScript with Bun?
Restate Is About Reliable Continuation, Not Merely Background Work⌗
Restate sits between callers and application services. Each call becomes an Invocation. Through the SDK Context, a service can perform stateful operations, call another service, schedule a timer, or run ordinary side-effecting code.
The central abstraction is the Journal. Completed ctx.run blocks, service calls, timers, and external events are recorded in the execution log. After a crash or rescheduling, a handler may execute again from its entry point, but completed operations replay their recorded results instead of contacting the third-party system again.
The more accurate description is therefore durable execution, not keeping one process alive forever.
This requires a clear boundary in application code. Non-deterministic work such as database access, HTTP requests, random values, and wall-clock time must use Restate’s durable actions. For third-party APIs, the usual entry point is ctx.run.
Two Very Different Kinds of Long-running Work⌗
The first design decision is to identify the API’s interaction model.
Long Synchronous Requests⌗
Some APIs hold an HTTP connection open and return a result after tens of seconds or several minutes. Certain LLM inference, export, and batch recognition endpoints behave this way.
Such a call can live in a durable step:
result, err := restate.Run(ctx, func(runCtx restate.RunContext) (Result, error) {
return provider.Generate(runCtx, input)
}, restate.WithName("generate"))
const result = await ctx.run("generate", () =>
provider.generate(input),
);
Restate stores the successful result and retries a failed step according to policy. ctx.run, however, cannot make a remote side effect magically exactly-once. The provider may accept a request while its response is lost in transit. An Invocation ID, business request ID, or deterministic UUID should therefore be passed to the provider as an idempotency key.
Another important limit is inactivity. If Restate receives no new Journal Entry from a service for some time, it assumes that the service may be unresponsive and attempts to suspend it. The current documentation gives a default inactivity timeout of one minute, followed by an abort timeout. A three-minute LLM request therefore needs adjusted service settings or, preferably, an asynchronous API. The HTTP client must still have explicit connection and total timeouts of its own.
Asynchronous Submission and Callback⌗
Other APIs immediately return a Job ID, then deliver the result through a webhook minutes or hours later. Video generation, bulk rendering, logistics, and human review commonly use this model.
Do not keep a permanent connection, Go goroutine, or Bun Promise polling forever. A better flow is:
- Create an awakeable and obtain its unguessable ID.
- Submit the remote job inside
ctx.run, including the callback URL and awakeable ID. - Suspend the current Invocation without consuming application compute.
- Verify the webhook signature, then resolve or reject the awakeable through the SDK or Restate HTTP API.
- Let Restate resume the original Invocation at the waiting point.
The essential Go code looks like this:
callback := restate.Awakeable[ProviderResult](ctx)
_, err := restate.Run(ctx, func(runCtx restate.RunContext) (restate.Void, error) {
return restate.Void{}, provider.Submit(runCtx, input, callback.Id())
}, restate.WithName("submit-provider-job"))
if err != nil {
return Result{}, err
}
result, err := callback.Result()
With the TypeScript SDK and Bun, the same model resembles an ordinary Promise:
const callback = ctx.awakeable<ProviderResult>();
await ctx.run("submit-provider-job", () =>
provider.submit(input, callback.id),
);
const result = await callback.promise;
A Workflow can alternatively use a named Durable Promise, completed by a shared handler that receives the webhook. Awakeables fit general Services and Virtual Objects; Durable Promises belong to one Workflow Execution and often express the business intent more clearly.
Scheduling: Waiting Does Not Mean Occupying a Worker⌗
Restate is most valuable during the waiting period. An Invocation awaiting a durable timer, awakeable, or Durable Promise can be suspended. Restate holds its state and reactivates the service when the event arrives. The application does not retain a goroutine, Promise, or container for every waiting job.
For a polling-only API, use durable sleep between status checks:
while (true) {
const status = await ctx.run("query-status", () => provider.status(jobId));
if (status.done) return status.result;
await ctx.sleep({ seconds: 30 });
}
The timer survives service and Restate restarts. When the requirement is merely to schedule one future action, the documentation recommends a delayed message instead of sleep + send. A delayed message lets the current Invocation finish, does not block a Virtual Object, and reduces pressure to retain an old Deployment Version for a long-lived Invocation.
Concurrency must also use Restate’s durable primitives. TypeScript uses RestatePromise.all, race, or any; Go uses SDK Futures with Wait or WaitFirst. Go code must not combine blocking Restate operations with goroutines, channels, and select, because completion order has to be journaled to remain deterministic during replay.
When one tenant or provider account must be serialized, use its account ID as a Virtual Object key. Exclusive handlers then form a queue per key. Avoid waiting for hours inside an exclusive handler, though, because every later call for that key will queue behind it. Splitting the process into callback-driven handlers is usually the better design.
Layer Timeouts, Retries, and Rate Limits⌗
A reliable third-party integration needs at least four time boundaries:
- HTTP client timeout: the maximum duration of one network attempt;
- durable step retry: which errors retry, at what interval, and how many times;
- business deadline: how long the remote job may remain outstanding;
- inactivity and abort timeouts: how Restate and the Service Deployment suspend or terminate execution.
Responses such as 429 and 503, along with transient network errors, are normally retryable. The TypeScript SDK can turn a provider’s Retry-After header into a RetryableError. Invalid input, failed authentication, and unsupported operations should become Terminal Errors instead of retrying forever.
Restate reliably advances a workflow, but it should not be mistaken for a complete provider quota manager. Global QPS, tenant fairness, provider concurrency slots, and cost budgets still need explicit flow control. Requests can be aggregated behind keyed Virtual Objects or a dedicated rate-limiting service. In either case, count submitted jobs separately from jobs waiting for callbacks: the latter are not necessarily consuming worker capacity.
Choosing Between Go and Bun⌗
There is no fundamental durability difference between the two. The Journal, timers, retries, suspension, and recovery come from Restate Server and the SDK protocol. The meaningful differences are development experience and deployment boundaries.
Choose Go⌗
Go is a natural fit for infrastructure-oriented services with stable boundaries: a unified provider gateway, webhook receiver, Kafka or Redpanda bridge, or high-throughput I/O service.
Its strengths include static typing, mature context.Context cancellation, controllable HTTP transports, predictable resource use, and single-binary deployment. The Restate Go SDK can also wrap an external Context so OpenTelemetry and custom values remain available inside a RunContext.
The caveat is that Restate concurrency is not ordinary Go concurrency. Developers must adopt the Future and Wait model instead of casually composing durable operations with goroutines. Otherwise the code may look concurrent while recovery order becomes non-deterministic.
Choose TypeScript with Bun⌗
The Restate TypeScript SDK officially supports Node.js, Bun, and Deno. Bun is attractive for fast-moving AI workflows: TypeScript types, native fetch, Promise composition, and the npm ecosystem keep provider integration code compact.
Its syntax also maps closely to the workflow: ctx.run, ctx.awakeable, and RestatePromise.race remain easy to read. Teams that frequently replace model providers, prompts, tool calls, and schemas will often write less glue than they would in Go.
The trade-off is operational validation. Pin Bun, Restate SDK, and provider client versions, then test HTTP/2, TLS, connection pooling, AbortSignal behavior, memory peaks, and SDK compatibility against real dependencies. The TypeScript SDK running on Bun does not guarantee that every Node.js dependency has identical edge behavior.
My default choice is Bun for a fast-changing, AI-SDK-heavy orchestration layer, and Go for stable, throughput-sensitive webhook, Kafka, and network boundaries. This does not have to be a binary choice. A Bun Workflow can invoke a Go Service through Restate while remaining in the same durable execution chain.
Observability: From Process Logs to an Invocation Timeline⌗
Long-running jobs are hard to diagnose precisely because nothing may execute for most of their lifetime. The primary observation unit must be the whole Invocation, not one process log.
The Restate Web UI exposes services, Invocation state, and the Journal. For a third-party job, we should be able to determine:
- whether Restate accepted the request;
- whether
submit-provider-jobcompleted or is retrying; - whether execution is waiting on a timer, awakeable, or Durable Promise;
- whether the webhook resolved successfully;
- which steps ran after resumption;
- whether a Terminal Error, cancellation, or exhausted retry policy ended the job.
Restate can also export an OpenTelemetry trace for Invocations and Context Actions and correlate incoming requests through W3C Trace Context. In production, continue the trace with child spans inside the Go HTTP client or Bun provider SDK. Record low-cardinality attributes such as provider, operation, job_id, attempt, and result. API keys, full prompts, callback tokens, and private user data do not belong in spans.
At the system layer, use Prometheus metrics. Restate Server exposes metrics on the NodeCtl 5122/metrics endpoint by default and provides official Grafana dashboards. Beyond server throughput, P99 latency, storage, and Invocation Task metrics, add business metrics for submission latency, completion latency, callback failures, retries, Terminal Errors, waiting jobs, and success rate by provider.
Logs, traces, and metrics should all correlate through the same Invocation ID and business Request ID. A job can then cross several restarts, one webhook, and multiple services while remaining traceable from the original order to an individual Journal Entry.
A Production Architecture⌗
For systems waiting on third-party APIs, I would use the following separation of responsibilities:
Business API -> Restate Workflow -> Provider Adapter -> Third-party API
Third-party Webhook -> Signature Verification -> Resolve Promise -> Workflow resumes
The Workflow owns business state and step order. The Provider Adapter owns timeouts, error mapping, idempotency keys, and vendor differences. The Webhook Handler performs authentication, deduplication, and wake-up only. A reliable call or event returns the final result to the business system.
Every external side effect receives a stable business ID, every wait has a deadline, every retry is explainable, and every state transition leaves evidence in an Invocation, trace, or metric.
Conclusion⌗
What I find most compelling about Restate is not another workflow DSL. It gives ordinary Go and TypeScript code a recoverable execution history. Processes can restart, waits can last for months, completed steps need not execute again, and the business flow still reads much like a normal function.
For slow third-party APIs, bounded synchronous calls belong in ctx.run. Truly long-lived jobs should use submit and callback with an awakeable or Durable Promise. Timers provide reliable deadlines and delayed scheduling, the Journal provides recovery, and OpenTelemetry plus Prometheus explain what the system is actually doing.
The Go versus Bun decision is ultimately not about which language is more durable. It is about which one fits the service boundary. Go works well for stable, infrastructure-oriented, high-throughput adapters. Bun works well for fast-changing, SDK-heavy AI orchestration. Combining both behind Restate is often more natural than forcing the entire system into one language.
I hope this is helpful. Happy hacking…