Client

Cancellation & Timeouts

Cancel in-flight requests and enforce timeouts using AbortSignal.

Cancel any call with a standard AbortController: pass its signal in the call() setup object. A cancelled request comes back as request-aborted in response['@thrownErrors'], never in the route's typed error union.

client.ts
import {initClient} from '@mionjs/client';
import type {MyApi} from './hello.routes.ts';

const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});

const controller = new AbortController();

const resultPromise = routes.sayHello('John').call({signal: controller.signal});

// e.g. on component unmount or a user action
controller.abort();

const [greeting, , response] = await resultPromise;
if (response['@thrownErrors']?.[0].type === 'request-aborted')
  console.log('Request was canceled');
else console.log(greeting);
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const routes = {
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
} satisfies Routes;

const myApi = mion.initRoutes(routes);

// the type the client uses, no server code ever reaches the browser
export type MyApi = typeof myApi;
A cancel started by the user is not a failure. Ignore request-aborted in your UI instead of showing an error.

Per-Request Timeout

Set a timeout in milliseconds to cancel a request that takes too long. It comes back as request-timeout in response['@thrownErrors'].

client.ts
import {initClient} from '@mionjs/client';
import type {MyApi} from './hello.routes.ts';

const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});

// this request fails if it takes longer than 5 seconds
const [greeting, error, response] = await routes
  .sayHello('John')
  .call({timeout: 5000});

// transport failures never join the route's typed errors; each is an open RpcError<string>
if (response['@thrownErrors']?.[0].type === 'request-timeout')
  console.log('Request took too long');
else if (!error) console.log(greeting);
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const routes = {
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
} satisfies Routes;

const myApi = mion.initRoutes(routes);

// the type the client uses, no server code ever reaches the browser
export type MyApi = typeof myApi;

Default Timeout

Set a default timeout for all requests in initClient. A per-request timeout overrides it.

client.ts
import {initClient} from '@mionjs/client';
import type {MyApi} from './hello.routes.ts';

// every request times out after 10 seconds
const {routes} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
  timeout: 10_000,
});

// uses the 10s default timeout
const [greeting] = await routes.sayHello('John').call();
console.log(greeting);

// 2s for this call only
const [, , response] = await routes.sayHello('Jane').call({timeout: 2000});
if (response['@thrownErrors']?.[0].type === 'request-timeout')
  console.log('too slow');
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const routes = {
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
} satisfies Routes;

const myApi = mion.initRoutes(routes);

// the type the client uses, no server code ever reaches the browser
export type MyApi = typeof myApi;

Global Abort

client.abort() cancels every in-flight request at once, for example when a single page app navigates away. New requests work normally afterwards.

client.ts
import {initClient} from '@mionjs/client';
import type {MyApi} from './hello-sum.routes.ts';

const {client, routes} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
});

const p1 = routes.sayHello('John').call();
const p2 = routes.utils.sum(5, 2).call();

client.abort();

// both come back as 'request-aborted'
const [, , response1] = await p1;
const [, , response2] = await p2;
if (response1['@thrownErrors']?.[0].type === 'request-aborted')
  console.log('first request canceled');
if (response2['@thrownErrors']?.[0].type === 'request-aborted')
  console.log('second request canceled');

// new requests work normally after abort
const [greeting] = await routes.sayHello('John').call();
console.log(greeting);
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const routes = {
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
client.destroy() also aborts all in-flight requests before clearing handlers.

Cancelling Middleware Calls and Batches

Cancellation and timeouts work the same with middleware and batches.

client.ts
import {HeadersSubset} from '@mionjs/core';
import {initClient, batch} from '@mionjs/client';
import type {MyApi} from './hello-sum-auth.routes.ts';

const {routes, middlewares} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
});

const controller = new AbortController();
middlewares.trace.onRequest((trace) =>
  trace(new HeadersSubset({'X-Trace-Id': crypto.randomUUID()}))
);

// cancellation works with middleware
const [greeting, , response] = await routes.sayHello('John').call({
  timeout: 5000,
  signal: controller.signal,
});
if (!response['@thrownErrors']) console.log(greeting);

// and with a batch
const [[sum, sumError, batchResponse], [greeting2, greetingError]] =
  await batch([routes.utils.sum(5, 2), routes.sayHello('Jane')]).call({
    timeout: 10_000,
  });
if (batchResponse['@thrownErrors'])
  console.log('Batch failed:', batchResponse['@thrownErrors'][0].publicMessage);
else if (!sumError && !greetingError) console.log(sum, greeting2);
server.routes.ts
import {HeadersSubset, FatalError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {getSession} from './session.ts';

const mion = createMionRouter();

const routes = {
  // the client sends a trace id with every request, the server echoes it back
  trace: mion.headersMiddleware(
    (ctx, {headers}: HeadersSubset<'X-Trace-Id'>): string =>
      headers['X-Trace-Id']
  ),
  // runs before every route; a returned FatalError ends the request and stays typed
  auth: mion.middleware((ctx): void | FatalError<'not-authorized'> => {
    if (!getSession(ctx.request.headers.get('cookie')))
      return new FatalError({
        publicMessage: 'Not Authorized',
        type: 'not-authorized',
      });
  }),
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
In a batch the timeout applies to the whole request, not to each route inside it.

Aborting Mutations

client.abort(), a signal and a timeout only end the wait on the client. The server sees a dropped connection, and nothing is undone. By then the server may not have received the request, may be halfway through it, or may already have committed it.

Treat an aborted mutation as unknown, not as failed. There is no client-side way to tell the difference.
Send an idempotency key with each logical action, so the server can return the stored result when you retry. After an abort, refetch the affected resource and show "syncing" rather than "failed".

Cancelling a query() is always safe, the worst case is wasted work on the server. See Queries and Mutations.

Copyright © 2026