Skip to main content
iii-stream is deprecated (iii-stream) and will be removed in an upcoming release. pubsub is deprecated (pubsub) and will be removed in an upcoming release. Behavior is unchanged for now. This page is the migration guide.
Use this page to move each iii-stream and pubsub use case to primitives every SDK already ships: worker-owned trigger types, explicit storage with queries, channels, and the durable queue. The sections below also list why both are deprecated and what stays supported.

Why they are deprecated

iii-stream bundles three concerns behind one API: a generic key/value store, a change notification, and a dedicated WebSocket transport. Every stream::set writes to storage even when the caller only wanted to notify someone, stored items have no owner that can bound or clean them up, and consumers depend on the stream envelope instead of a domain contract. Authorization is handled by a separate auth_function that only covers the dedicated WebSocket. The pubsub worker is a global topic bus. It drops the namespace and metadata of a binding, delivers fire-and-forget with silent drops, and never evaluates condition_function_id. The replacements already exist in Node, Browser, Python, Rust and Go: function and trigger-type registration, trigger bindings, channels, the state and database workers, and the queue worker. They put each concern with the worker that owns the data.
Do not replace stream::set with state::set mechanically. That keeps the storage cost of writing every event and loses the notification. Pick the recipe below that matches what the data is for.

Timeline and support window

What the warnings look like:
  • Engine: a warn log with target iii::deprecation the first time a caller uses a deprecated function, binds a deprecated trigger type, or connects to the dedicated Streams WebSocket, then at most once every 10 minutes per entry point and caller. Function calls and bindings are keyed by the calling worker; WebSocket connections are keyed by client IP. Starting iii-stream and its WebSocket listener warns once per process. The log names the entry point, the calling worker when known and this guide. It never contains payloads, stream, group or item ids, headers or query strings. Direct calls such as trigger({ function_id: "stream::set" }) from any SDK version are covered. When iii-stream is not running, a stream::* call still fails with function_not_found (code unchanged), and the message now explains how to enable iii-stream and links this guide.
  • Node and Browser SDKs: @deprecated JSDoc on createStream and IStream, so editors strike them through. No runtime change.
  • Python SDK: calling create_stream or subclassing IStream emits a FutureWarning pointing at your call site. Nothing is raised and behavior is unchanged.
  • Rust SDK: #[deprecated] on the legacy stream helpers, IStream, create_stream, the IIITrigger::Stream, IIITrigger::StreamJoin and IIITrigger::StreamLeave variants, and the pubsub items SubscribeTriggerConfig and IIITrigger::Subscribe.
  • pubsub worker: the worker logs a warning once at startup; on publish, at most once every 10 minutes per caller; and on subscribe trigger registration, at most once every 10 minutes for the whole worker (the subscriber is not identified). Traffic on topic stream.events, used by the engine’s own iii-stream bridge, never warns.

What is deprecated and what is not

Deprecated with iii-stream:
  • The iii-stream engine worker.
  • Functions stream::set, stream::get, stream::delete, stream::list, stream::list_groups, stream::list_all, stream::send and stream::update.
  • Trigger types stream, stream:join and stream:leave.
  • The dedicated Streams WebSocket listener (default 127.0.0.1:3112) and its clients.
  • The auth_function setting of iii-stream.
  • SDK helpers: Node and Browser createStream and IStream; Python create_stream and IStream; the Rust legacy stream provider and helpers; models that only exist for the legacy Streams contract.
Deprecated with pubsub (the registry worker pubsub, formerly iii-pubsub):
  • Function publish, trigger type subscribe and the pubsub configuration entry.
  • Rust SDK SubscribeTriggerConfig and IIITrigger::Subscribe.
Not deprecated, keep using them:
  • Function, trigger-type and trigger registration APIs in every SDK.
  • Channels, channel readers and writers, and StreamChannelRef.
  • StreamRequest and StreamResponse used for request/response streaming.
  • UpdateOp, MergePath, UpdateOpError, and the Rust StreamSetResult, StreamUpdateResult and StreamDeleteResult types. The state worker uses them.
  • The auth_function_id of iii-worker-manager and rbac-proxy. That is connection authorization for RBAC listeners, unrelated to the iii-stream auth_function. See Access Control.
  • The queue worker, including iii::durable::publish and durable:subscriber.
  • Provider token streaming in LLM workers, which already uses channels.

Choose a replacement

A trigger is a notification mechanism, not a database or durable event log. If history or replay is required, define and retain the source of truth in the worker that owns the data. Do not build another generic, unbounded event store. Prefer small notification payloads plus a query for large datasets, or bounded record snapshots with a revision. Find your current usage: Runnable TypeScript versions of the three recipes live in the repository under sdk/packages/node/iii-example/src/streams-migration/: The snippets below are shortened from those files.

Recipe 1: Ephemeral notifications

Use this when consumers need to react to something that happened and nobody needs to read it back later: a lifecycle change, a status flip, a “work is done” signal. The worker that produces the event declares a trigger type and keeps its own table of bindings. Consumers bind a function to it the same way they bind to http or cron. See Declaring a Trigger Type and Dispatch events to bound functions for the full API in every SDK. Producer:
Consumer:
A consumer that registers before the provider is connected is kept pending and activates when the trigger type appears. See Registering before a trigger type is available.

Recipe 2: Stored records with live updates

Use this when consumers need the current data and also want to know when it changes: a dashboard, a list view, a sync job. Storage and notification are two separate things owned by the same domain worker.
  1. Store records in storage the domain worker owns (state, database, or your own store), with a revision number per record.
  2. Expose read functions: orders::get and a paginated orders::list (cursor plus limit).
  3. After the write is committed, emit a small change notification through a worker-owned trigger type (orders::changed with { order_id, revision }), built as in Recipe 1.
  4. Consumers bind first, then do an initial read, then apply notifications. They repeat the read to recover after a reconnect.
Producer write path:
For concurrent writers to the same record, guard the write with state::compare-and-set or your database’s transaction so revisions stay monotonic. Consumer:
The notification carries an id and a revision, not the record. Consumers that fall behind fetch the latest state instead of replaying every change, and large records never travel through the trigger.

Recipe 3: Continuous content for one operation

Use this for content produced by one running operation and read by one reader: progress output, tokens, frames, a file. A channel has an explicit lifetime and backpressure, and nothing is stored. The reader creates the channel and passes the writer ref to the function that produces the content:
Rules for channels:
  • One channel per operation and reader. For fan-out to many consumers, use Recipe 1 or 2 instead.
  • End the writer when the operation finishes or fails. When the reader leaves, the engine closes the writer. See Backpressure and lifecycle.
  • Bound the content: cap size or duration, and never queue an unbounded backlog for a slow reader.
  • For bidirectional traffic, create two channels.

Provider and consumer obligations

iii-stream hid these responsibilities. With an owned trigger type they belong to your code. The provider (the worker that owns the trigger type):
  • Owns registration and unregistration. Keep a binding table keyed by trigger id; add in registerTrigger, remove in unregisterTrigger. When a consumer disconnects, the engine removes its bindings and the provider receives the unregister callback.
  • Validates filters. Reject unknown or malformed config by throwing from registerTrigger. Never let a filter widen what the caller is allowed to see.
  • Preserves the target namespace and metadata. Pass binding.namespace and binding.metadata on every trigger() so the event reaches the consumer’s namespace.
  • Bounds fan-out. Cap bindings per type and per consumer, use timeouts, and never spawn an unbounded task per event or keep an unbounded queue for a slow consumer.
  • Emits after commit. For stored data, notify only after the mutation is durable. Never report success while dropping data or events.
The consumer (the worker that binds a function):
  • Handles duplicates. Make handlers idempotent; delivery can repeat after retries or reconnects.
  • Tracks revisions or cursors. Ignore notifications older than what it already applied.
  • Recovers after reconnect. Re-run the initial read (Recipe 2) because notifications sent while it was away are not replayed.
  • Cleans up. Unregister bindings it no longer needs and close channels it opened.

Authorization and tenant isolation

A transport change alone does not reproduce the old policies. Moving a client from the dedicated Streams WebSocket to Browser SDK subscriptions with type: "stream" changes the connection, not the dependency on iii-stream, and it keeps the deprecated trigger types. For tenant isolation, give each tenant or browser session its own namespace through the RBAC listener’s namespaces grant, and have the provider deliver only events that belong to binding.namespace. Do not trust a tenant id taken from the consumer’s config; derive it from the namespace or check it against it.
The auth_function_id of iii-worker-manager and rbac-proxy is not deprecated. Only the auth_function of iii-stream and the stream:join / stream:leave trigger types are.

A note on stream::send

stream::send already avoids the key/value write that stream::set does, but it still runs through iii-stream and is deprecated with it. Changing stream::set to stream::send is not the final migration, and it changes the event envelope consumers receive. Move straight to Recipe 1 or Recipe 3.

Migrate from pubsub

Things to check while migrating:
  • Payload envelope. pubsub and durable:subscriber deliver the published data as-is. An owned trigger type defines its own payload, so update consumer handlers.
  • Namespaces. pubsub ignored namespaces: topics were global and subscriber functions resolved in the default namespace. Owned trigger types and queue subscribers respect the binding namespace, so a consumer in another namespace must bind there.
  • Silent drops. A publish sent with TriggerAction.Void() when no pubsub worker is running is dropped without an error to the caller. The replacements make delivery explicit; do not rely on that behavior.
  • Order of removal. The iii-stream bridge adapter uses publish and subscribe internally. If you run it, keep pubsub until iii-stream is gone.

Existing stream data

During the support window, data stored by iii-stream stays readable through stream::get, stream::list and stream::list_groups. To keep it, copy what you still need into storage your domain worker owns while iii-stream is running, and map stream, group and item ids to your own keys. Do not delete the iii-stream store files or Redis keys until your consumers run without iii-stream. A documented export path, the id mapping and a rollback plan are part of the removal phase and will be published before storage access is removed in an upcoming release.

Worked examples in the tutorials

The Linkly tutorial and the real-time todo how-to no longer use iii-stream or pubsub. They use worker-owned trigger types and the queue worker:
  • Ch. 5: Stream live clicks is a worked example of Recipe 2. The click-streamer worker owns the click-streamer::click trigger type and announces each click after its row is committed, and link::click_summary gives a late listener the current total.
  • Ch. 7: Bring in the browser binds that trigger type from the Browser SDK through rbac-proxy, reads the total first, and reads it again after a reconnect.
  • Ch. 4: Make it durable fans link.created and link.updated out on durable topics with iii::durable::publish and durable:subscriber.
  • Build a real-time todo app outlines Recipe 2 for a browser app: stored todos, todos::get and todos::list, and an owned todos::changed trigger type.

Checklist

  • List every call to stream::*, publish, and every binding of stream, stream:join, stream:leave and subscribe, including calls whose failures your code ignores.
  • Pick a recipe per use case and name the worker that owns each piece of data.
  • Move authorization to the RBAC listener and the provider’s registerTrigger validation.
  • Migrate producers and their consumers together. If you must publish to both paths for a while, define event identity, which consumer reads which path, and a deadline.
  • Copy any stream data you still need into domain storage.
  • Run your consumers with iii-stream and pubsub absent from worker-compose.yaml and confirm no deprecation warnings remain in the engine log.