Skip to main content

Generated Bindings

Type-safe frontend bindings generated from your Rust code and Forge macros.

When to Run It

forge generate is a manual step — it is not part of cargo build and does not run automatically when you change handler signatures. Run it whenever you:

  • Add a new #[forge::query], #[forge::mutation], or other handler
  • Rename or remove a handler
  • Change a handler's arguments or return type
  • Add, rename, or remove a #[forge::model] or #[forge::forge_enum]
forge generate

forge generate auto-detects the frontend target from frontend/ and can also be forced with --target sveltekit or --target dioxus.

If you forget to run it, forge check will catch the drift — it diffs the current bindings against freshly generated output and reports staleness.

Automating the step

To ensure bindings never drift, add forge generate -y to your workflow in one of these places:

Pre-commit hook (.git/hooks/pre-commit):

#!/bin/sh
forge generate -y
git add frontend/src/lib/forge # or frontend/src/forge for Dioxus

package.json script (SvelteKit projects):

{
"scripts": {
"generate": "forge generate -y",
"dev": "forge generate -y && vite dev"
}
}

Makefile:

generate:
forge generate -y

dev: generate
cargo run

In CI, run forge check rather than forge generate — it validates that checked-in bindings are already up to date without overwriting them.

SvelteKit Output

Your Rust models, input DTOs, and function signatures become TypeScript:

// types.ts - generated from #[forge::model] and input DTOs
export interface User {
id: string;
email: string;
created_at: string;
}

// api.ts - generated from #[forge::query] and #[forge::mutation]
export const getUser = (args: { id: string }): Promise<User> =>
getForgeClient().call("get_user", args);

export const createUser = (args: {
name: string;
email: string;
}): Promise<User> => getForgeClient().call("create_user", args);

// Reactive subscription (Svelte store)
export const getUserStore$ = (args: { id: string }) =>
createSubscriptionStore<{ id: string }, User>("get_user", args);

What Happens

The forge generate command:

  1. Scans your src/ directory for models, enums, and functions
  2. Extracts type information from Rust macros
  3. Generates TypeScript interfaces with correct nullability
  4. Creates API bindings that call your functions with type safety
  5. Produces subscription factories for queries with real-time support

When a function takes a single argument whose type name ends in Args or Input and that type exists in the schema registry, the generator uses it directly as the params type instead of wrapping it in a generated struct. This means your custom input DTOs pass through as-is.

Output goes to frontend/src/lib/forge/ by default for SvelteKit projects.

Dioxus Output

For Dioxus projects, Forge generates Rust bindings under frontend/src/forge/:

// api.rs - generated from #[forge::query] and #[forge::mutation]
pub struct GetUserParams {
pub id: String,
}

impl GetUserParams {
pub fn new(id: impl Into<String>) -> Self {
Self { id: id.into() }
}
}

pub async fn get_user(client: &ForgeClient, args: GetUserParams) -> Result<User, ForgeClientError> {
client.call("get_user", args).await
}

// Default: one-shot query
pub fn use_get_user(args: GetUserParams) -> QueryState<User> {
use_forge_query("get_user", args)
}

// Opt-in: real-time subscription
pub fn use_get_user_live(args: GetUserParams) -> SubscriptionState<User> {
use_forge_subscription("get_user", args)
}

// Signal variant — returns Signal<QueryState<T>> for reactive child component passing
pub fn use_get_user_signal(args: GetUserParams) -> Signal<QueryState<User>> {
use_forge_query_signal("get_user", args)
}

// Mutations return a typed Mutation handle
pub fn use_create_user() -> Mutation<CreateUserParams, User> {
use_forge_mutation("create_user")
}

The generated mod.rs also re-exports ForgeProvider, ForgeClient, error types, and the Dioxus hooks from forge-dioxus.

use_<name>() is a one-shot query. Append _live for real-time subscriptions. Append _signal for Signal<QueryState<T>> variants useful when passing reactive query state to child components. Mutations return a Mutation<Args, Result> with a .call(args) method. Generated params and DTO structs include new(...) constructors plus builder methods for optional fields, so common calls avoid verbose Rust struct literals.

Dioxus Auth

The generated mod.rs re-exports ForgeAuthProvider from forge-dioxus. Wrap your app in it to provide auth context:

use crate::forge::ForgeAuthProvider;

fn App() -> Element {
rsx! {
ForgeAuthProvider {
// your routes / app content
}
}
}

Inside the provider tree, use these hooks:

HookReturnsPurpose
use_forge_auth()ForgeAuthAuth handle with login(), logout(), update_tokens(), viewer::<T>()
use_viewer::<T>()Option<T>Typed viewer access (shorthand for use_forge_auth().viewer::<T>())
use_auth_key()usizeCounter that increments on identity changes, useful for router keying

Type Mapping

Rust to TypeScript

Rust TypeTypeScript Type
String, &strstring
Uuidstring
i32, i64, f32, f64number
boolboolean
Instantstring
LocalDate, LocalTimestring
DateTime<Utc>string
NaiveDateTimestring
NaiveDatestring
Timestampstring
Option<T>T | null
Vec<T>T[]
HashMap<K, V>Record<string, V>
Value (JSON)unknown
Page<T>Page<T>
Cursorstring
UploadFile | Blob
Bytes (return)Blob
Bytes (arg)Uint8Array

Rust to Dioxus

Rust TypeDioxus Type
String, &strString
UuidString
InstantString (ISO 8601)
DateTime<Utc>String
NaiveDateTimeString
NaiveDateString
TimestampString
i32i32
i64i64
f32f32
f64f64
u32, usize, isizei64
u64i64
boolbool
Option<T>Option<T>
Vec<T>Vec<T>
Page<T>forge_core::Page<T>
UploadForgeUpload
serde_json::ValueJsonValue
BytesVec<u8>

Enums to Union Types

Rust enums become TypeScript union types:

#[forge::forge_enum]
pub enum TaskStatus {
Draft,
Active,
Completed,
}
export type TaskStatus = "draft" | "active" | "completed";

Query Bindings

One-Shot RPC

Call a query once and get the result:

import { getUser, listTodos } from "$lib/forge";

// With arguments
const user = await getUser({ id: userId });

// No arguments
const todos = await listTodos();

Returns a Promise<T> that resolves with the data or rejects with a ForgeClientError.

Reactive Subscriptions

Subscribe to query changes with automatic updates:

import { getUserStore$ } from "$lib/forge";

// Create a subscription store
const userStore = getUserStore$({ id: userId });

// Use in Svelte component
$: ({ loading, data, error, stale } = $userStore);

Svelte 5 Runes

For Svelte 5 runes-based reactivity:

import { getUser$ } from '$lib/forge';

// Returns a reactive object
const user = getUser$({ id: userId });

// Access properties directly
{#if user.loading}
Loading...
{:else if user.error}
Error: {user.error.message}
{:else}
Welcome, {user.data.name}
{/if}

The $ suffix functions use $state internally for fine-grained reactivity.

SvelteKit Lower-Level APIs

The generated stores are built on top of a few primitives you can use directly:

FunctionPurpose
createQueryStore(fn, args)Underlying factory used by generated store functions. Creates a subscription store for any function name and argument object.
setForgeClient(client)Manually inject a ForgeClient into Svelte context. Useful for testing or custom setups where you don't use the generated provider.
setAuthState(state)Manually inject auth state into Svelte context. Same use case as setForgeClient but for the auth layer.

Subscription State

All subscription stores expose the same shape:

SvelteKit:

interface SubscriptionResult<T> {
loading: boolean; // true until first data arrives
data: T | null; // the query result
error: Error | null;
stale: boolean; // reserved for reconnect-aware UIs
}
FieldDescription
loadingInitial fetch in progress
staleCurrently reserved in the Svelte runtime; use createConnectionStore() for connection status
errorInitial query or subscription registration failed

The current Svelte runtime keeps stale at false and exposes connection state separately through createConnectionStore().

Dioxus: The SubscriptionState<T> type also includes a connection_state: ConnectionState field that tracks the underlying SSE connection (Disconnected, Connecting, Connected). This field is not present in the Svelte runtime.

In Dioxus generated types, WorkflowStepState.status is an untyped String rather than a union type. TypeScript targets get a proper string union, but the Dioxus codegen emits a plain String since Rust doesn't have an equivalent inline union.

Mutations

Mutations return Promises; subscriptions are not required for mutation calls:

import { createUser, updateTodo, deleteTodo } from "$lib/forge";

// Create
const user = await createUser({ name: "Alice", email: "alice@example.com" });

// Update
await updateTodo({ id: todoId, completed: true });

// Delete
await deleteTodo({ id: todoId });

On success, all active subscriptions that depend on affected tables re-evaluate automatically via server-side change detection.

File Uploads

The Upload type maps to File | Blob on the client:

#[forge::mutation]
pub async fn upload_avatar(
ctx: &MutationContext,
file: Upload,
user_id: Uuid,
) -> Result<String> {
let bytes = file.bytes();
let filename = file.name();
let content_type = file.content_type();
// Store file, return URL
}
import { uploadAvatar } from "$lib/forge";

// From file input
const url = await uploadAvatar({
file: inputElement.files[0],
userId: currentUserId,
});

// From Blob
const blob = new Blob([data], { type: "application/pdf" });
await uploadDocument({ file: blob, name: "report.pdf" });

When arguments contain File or Blob, the client automatically:

  • Switches to multipart/form-data encoding
  • Encodes non-file arguments into the _json multipart field
  • Extracts filename and content type from the File object

Auto-Reconnect

The client handles connection drops automatically:

BehaviorValue
Max attempts10
Initial delay1 second
BackoffExponential with jitter
Max delay30s (SvelteKit), 16s (Dioxus)

The Dioxus client caps at 16 seconds (1s * 2^min(attempts, 4)), while SvelteKit caps at 30 seconds.

During reconnection:

  • The SSE transport reconnects with backoff
  • Active query subscriptions re-register on reconnect
  • One-shot RPC calls do not retry automatically

Connection Lifecycle

import { createConnectionStore } from "$lib/forge";

const connection = createConnectionStore();

$: state = $connection; // 'disconnected' | 'connecting' | 'connected'

Connection IDs prevent race conditions. If a reconnect starts while another is in progress, the stale attempt is cancelled.

Token Change Detection

When the auth token changes, the client detects it and reconnects:

// In auth.svelte.ts
auth.setAuth(newAccessToken, newRefreshToken, user); // Triggers reconnect
auth.clearAuth(); // Triggers reconnect

The client hashes the current token and compares it before each subscription registration. If the hash differs from the connected token, it triggers a full reconnect to re-authenticate subscriptions.

This handles:

  • User login (token acquired)
  • Token refresh (token replaced)
  • User logout (token cleared)

Generated Files

SvelteKit files

After forge generate, a SvelteKit target gets:

FileContents
types.tsInterfaces from #[forge::model], input DTOs, and union types from #[forge::forge_enum]
api.tsQuery, mutation, job, and workflow bindings
stores.tsRe-exports from @forge-rs/svelte
runes.svelte.tsSvelte 5 toReactive() and toReactiveMutation() helpers, plus ReactiveQuery and ReactiveMutation interfaces
reactive.svelte.tsRunes-based subscription functions (getUser$), generated when queries exist
auth.svelte.tsAuth store (when auth configured)
index.tsBarrel export

Dioxus files

After forge generate --target dioxus, a Dioxus target gets:

FileContents
types.rsRust structs for models and input DTOs, plus enums from #[forge::forge_enum]
api.rsQuery hooks, _live subscription hooks, Mutation handles, job/workflow hooks
mod.rsRe-exports generated modules plus forge-dioxus runtime types

CLI Options

forge generate [OPTIONS]
OptionDescription
--forceRegenerate even if files exist
--output, -oOutput directory (defaults to the target's standard binding path)
--targetFrontend target (sveltekit or dioxus)
--src, -sSource directory to scan (default: src)
-y, --yesAuto-accept prompts (for CI)

Client API Reference

The generated bindings are thin wrappers over the @forge-rs/svelte runtime. This section documents the runtime API directly so you know what you're working with.

ForgeProvider

ForgeProvider is a Svelte component that creates the ForgeClient, opens the SSE connection, and puts both into Svelte context. Every page that uses generated bindings must be inside it.

<!-- frontend/src/routes/+layout.svelte -->
<script lang="ts">
import { ForgeProvider } from '@forge-rs/svelte';
import { auth } from '$lib/forge/auth.svelte';
import { type Snippet } from 'svelte';

let { children }: { children: Snippet } = $props();
</script>

<ForgeProvider
url="http://localhost:3000"
getToken={() => auth.token}
onAuthError={(err) => auth.clearAuth()}
onMutationError={(err) => console.error('Mutation failed', err)}
onConnectionChange={(state) => console.log('SSE', state)}
>
{@render children()}
</ForgeProvider>
// frontend/src/routes/+layout.ts
export const ssr = false; // Required: EventSource and localStorage are browser-only
PropTypeRequiredDescription
urlstringYesBase URL of your Forge backend (no trailing slash)
getToken() => string | null | Promise<string | null>NoReturns the current access token. Called before every RPC and SSE connect.
onAuthError(err: ForgeError) => voidNoFired on HTTP 401/403 from RPC or SSE. Use it to redirect to login or clear auth state.
onMutationError(err: ForgeClientError) => voidNoGlobal fallback for mutation errors not handled locally. Good for toast notifications.
onConnectionChange(state: ConnectionState) => voidNoFired whenever the SSE state changes ('disconnected', 'connecting', 'connected').
signalsSignalsConfig | falseNoAnalytics configuration. Pass false to disable entirely. See Signals below.

ForgeClientConfig

If you need to create a client manually (e.g., for testing), use createForgeClient:

import { createForgeClient } from '@forge-rs/svelte';

const client = createForgeClient({
url: 'http://localhost:3000',
getToken: () => localStorage.getItem('token'),
refreshToken: async () => {
const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
if (!res.ok) return null;
const { access_token } = await res.json();
return access_token;
},
onAuthError: (err) => router.goto('/login'),
onMutationError: (err) => showToast(err.message),
timeout: 30000,
});
OptionTypeDefaultDescription
urlstringBackend base URL.
getToken() => string | null | Promise<string | null>undefinedToken provider. Called before every request.
refreshToken() => Promise<string | null>undefinedCalled automatically on 401. Should return a fresh access token or null. The failed call is retried once. Concurrent 401s coalesce on a single refresh attempt.
onAuthError(err: ForgeError) => voidundefinedFired when auth fails and cannot be recovered via refreshToken.
onMutationError(err: ForgeClientError) => voidundefinedGlobal mutation error sink.
timeoutnumber (ms)30000SSE connection timeout.

ForgeClientError

All RPC and subscription failures throw ForgeClientError, which extends Error:

import { ForgeClientError } from '@forge-rs/svelte';

try {
await createUser({ email: 'alice@example.com' });
} catch (err) {
if (err instanceof ForgeClientError) {
if (err.isRateLimited()) {
console.warn(`Slow down, retry in ${err.retryAfterSecs}s`);
} else if (err.isValidation()) {
console.error('Bad input:', err.details);
} else if (err.isUnauthorized()) {
router.goto('/login');
} else {
console.error(`[${err.code}] ${err.message}`);
}
}
}
PropertyTypeDescription
codestringMachine-readable error code (e.g., RATE_LIMITED, VALIDATION_ERROR, UNAUTHORIZED, NOT_FOUND, INTERNAL).
messagestringHuman-readable description.
retryAfterSecsnumber | undefinedPresent on RATE_LIMITED errors. Seconds to wait before retrying.
detailsRecord<string, unknown> | undefinedExtra context from the server (e.g., field-level validation errors).
isRateLimited()() => booleancode === 'RATE_LIMITED'
isUnauthorized()() => booleancode === 'UNAUTHORIZED'
isValidation()() => booleancode === 'VALIDATION_ERROR'

Store Primitives

The generated subscription functions are built from three primitives exported by @forge-rs/svelte. You can use them directly for advanced cases.

createQueryStore

One-shot fetch with no live updates. Fetches once on creation and exposes refetch() to re-run.

import { createQueryStore } from '@forge-rs/svelte';

const store = createQueryStore<{ id: string }, User>('get_user', { id: userId });

Returns a QueryStore<T> with:

MemberTypeDescription
subscribe(run: (v: QueryResult<T>) => void) => ()=> voidSvelte store protocol.
refetch() => Promise<void>Re-runs the RPC call.
reset() => voidResets to { loading: true, data: null, error: null }.

createSubscriptionStore

Live subscription that re-runs whenever the server detects a relevant DB change:

import { createSubscriptionStore } from '@forge-rs/svelte';

const store = createSubscriptionStore<{ id: string }, User>('get_user', { id: userId });

Returns a SubscriptionStore<T> with the same members as QueryStore<T> plus:

MemberTypeDescription
unsubscribe() => voidDetaches from SSE and cleans up. Call when you manage lifecycle manually.

The store auto-unsubscribes when its last Svelte subscriber leaves (last $-ref removed from the DOM). Manual cleanup is only needed when using the store outside a component.

createConnectionStore

Tracks the SSE connection state:

import { createConnectionStore } from '@forge-rs/svelte';

const conn = createConnectionStore();
// $conn === 'disconnected' | 'connecting' | 'connected'

createJobStore

Dispatches a job and streams its progress over SSE:

import { createJobStore } from '@forge-rs/svelte';

const job = createJobStore<ExportArgs, ExportResult>('export_users', { format: 'csv' });

The store value is JobState<TOutput> & { loading: boolean }:

FieldTypeDescription
jobIdstringUUID assigned by the server.
status'pending' | 'claimed' | 'running' | 'completed' | 'failed' | 'cancelled' | 'retry' | 'dead_letter' | 'cancel_requested'Current job status.
progressnumber | null0–100 progress percentage, if reported.
messagestring | nullLatest progress message from the job.
outputTOutput | nullFinal output once status is completed.
errorstring | nullError message when status is failed.
loadingbooleantrue until the job ID is received.

createWorkflowStore

Starts a durable workflow and streams step-level state over SSE:

import { createWorkflowStore } from '@forge-rs/svelte';

const wf = createWorkflowStore<OnboardArgs, OnboardResult>('onboard_user', { userId });

The store value is WorkflowState<TOutput> & { loading: boolean }:

FieldTypeDescription
workflowIdstringUUID assigned by the server.
status'pending' | 'running' | 'sleeping' | 'waiting' | 'completed' | 'failed' | 'blocked_missing_version' | 'blocked_signature_mismatch' | 'blocked_missing_handler'Overall workflow status. The three blocked_* variants indicate the workflow cannot resume until an operator intervenes.
stepstring | nullName of the currently executing step.
waitingForstring | nullName of the external event the workflow is waiting on.
stepsWorkflowStepState[]All step records with individual status and error.
outputTOutput | nullFinal output once status is completed.
errorstring | nullError message when status is failed.

fireMutation

Fire-and-forget wrapper that routes errors to the global onMutationError handler:

import { fireMutation } from '@forge-rs/svelte';
import { deleteTodo } from '$lib/forge';

// Errors go to onMutationError; no await needed
fireMutation(deleteTodo, { id: todoId });

// Per-call override
fireMutation(deleteTodo, { id: todoId }, (err) => showToast(err.message));

Use fireMutation for fire-and-forget interactions (delete, toggle, increment) where the UI doesn't need to track pending state. Use await mutationFn(args) or the runes $() form when you need local pending/error state.

createOptimisticMutation

Applies a local patch immediately and reverts automatically on error or after a TTL:

import { createOptimisticMutation, createSubscriptionStore } from '@forge-rs/svelte';
import { reorderTask } from '$lib/forge';

const todos = createSubscriptionStore('list_todos', null);

const reorder = createOptimisticMutation(
reorderTask,
todos,
(data, args) => data.map(t => t.id === args.id ? { ...t, position: args.position } : t),
{ ttlMs: 3000 }, // auto-revert after 3s if no SSE confirmation
);

// Read from reorder.data, not todos directly
reorder.fire({ id: todoId, position: 2 });

The data member is a Readable<TData | null> that layers the optimistic patch over the live subscription. Once the server confirms the change via SSE, the patch is dropped and the server data takes over.

Auth Token Management

The auth.svelte.ts file generated alongside the bindings exports a global auth object:

import { auth } from '$lib/forge/auth.svelte';

// After successful login
const result = await signIn({ email, password });
auth.setAuth(result.access_token, result.refresh_token, result.user);

// After logout
await signOut();
auth.clearAuth();

// Start background token rotation (call once, on mount)
auth.startRefreshLoop('http://localhost:3000/auth/refresh');

setAuth and clearAuth both update localStorage and trigger an SSE reconnect, so active subscriptions re-register under the new identity. Writing to localStorage directly bypasses the reconnect and leaves subscriptions authenticated as the previous user.

MethodDescription
auth.setAuth(access, refresh, user)Persists tokens, updates auth.token and auth.user, triggers reconnect.
auth.clearAuth()Clears stored tokens, resets state, triggers anonymous reconnect.
auth.startRefreshLoop(url)Polls the refresh endpoint before token expiry. Concurrent 401s coalesce.
auth.tokenCurrent access token, or null.
auth.userTyped viewer object (pass generic: auth.user as MyUser), or null.
auth.loadingtrue until the first token resolution completes.

Signals

ForgeSignals handles product analytics and diagnostics. It is created inside ForgeProvider and is mostly automatic.

<ForgeProvider
url="http://localhost:3000"
signals={{
enabled: true,
autoPageViews: true,
autoCaptureErrors: true,
autoWebVitals: true,
autoNetworkEvents: true,
flushInterval: 5000,
maxBatchSize: 20,
respectDnt: true,
persistQueue: true,
}}
>

To track custom events, get the signals instance from context:

import { getForgeSignals } from '@forge-rs/svelte';

const signals = getForgeSignals();
signals.track('plan_upgraded', { plan: 'pro', seats: 10 });
signals.identify(user.id, { email: user.email });
Config OptionDefaultDescription
enabledtrueMaster switch. Set false or pass signals={false} to disable entirely.
autoPageViewstrueTracks navigation via history.pushState / popstate.
autoCaptureErrorstrueCaptures unhandled window.onerror and unhandledrejection events.
autoWebVitalstrueCaptures LCP, CLS, INP, FCP, TTFB, and navigation timing.
autoNetworkEventstrueTracks online/offline transitions.
flushInterval5000How often (ms) buffered events are flushed to the server.
maxBatchSize20Maximum events per flush batch.
respectDnttrueDisables collection if the browser sends DNT: 1 or Sec-GPC: 1.
persistQueuetrueSaves the outbound queue to localStorage so events survive page reloads.

Correlation IDs are automatically injected into RPC request headers, linking frontend events to backend traces.

Usage in SvelteKit Components

<script lang="ts">
import { listTodos$, createTodo, toggleTodo } from '$lib/forge';

const todos = listTodos$();

async function addTodo(title: string) {
await createTodo({ title });
// Refetches are handled by the subscription updates
}

async function toggle(id: string, completed: boolean) {
await toggleTodo({ id, completed });
}
</script>

{#if todos.loading}
<p>Loading...</p>
{:else if todos.error}
<p>Error: {todos.error.message}</p>
{:else}
<ul>
{#each todos.data as todo}
<li>
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggle(todo.id, !todo.completed)}
/>
{todo.title}
</li>
{/each}
</ul>
{/if}

For Dioxus, the same backend contract is consumed through generated Rust hooks:

use dioxus::prelude::*;

use crate::forge::{CreateTodoInput, use_create_todo, use_list_todos_live};

#[component]
fn TodoList() -> Element {
let create_todo = use_create_todo();
let todos = use_list_todos_live();

rsx! {
button {
onclick: move |_| {
let create_todo = create_todo.clone();
spawn(async move {
let _ = create_todo.call(CreateTodoInput::new("Ship it")).await;
});
},
"Add"
}

ul {
for todo in todos.data.clone().unwrap_or_default() {
li { "{todo.title}" }
}
}
}
}

Under the Hood

SSE Transport

Subscriptions use Server-Sent Events with a dedicated connection. The client:

  1. Opens an EventSource to /_api/events with the auth token
  2. Receives a session ID and session secret on connection
  3. Registers each subscription via POST to /_api/subscribe
  4. Receives updates via the SSE channel with target routing

SSE was chosen over WebSocket for:

  • HTTP/2 multiplexing support
  • Compatible with standard proxy configuration
  • Automatic reconnection built into EventSource
  • Simpler firewall traversal

Subscription Re-registration

On reconnect, the client re-registers all active query subscriptions. Each subscription tracks:

  • Function name and arguments
  • Failed attempt count
  • Callback reference

After 3 consecutive re-registration failures, the subscription emits a MAX_RETRIES_EXCEEDED error and stops retrying.

Connection ID Pattern

Each connect() call increments a connection ID. All async operations check this ID before modifying state:

const currentConnectionId = ++this.connectionId;
// ... async work ...
if (currentConnectionId !== this.connectionId) return; // Stale, abort

This prevents race conditions when rapid reconnects overlap.