Bitskiff

Limits and capacity

On this page

Every number a project runs into, what it refuses, and what clears it.

Session capacity

Capacity is one session's own setting, not a project quota. You set it on the open.

SettingDefaultCeilingHow to set itWhat a client sees when it is full
maxClients4500open({ maxClients: 8 })join() is refused with a BitskiffError whose code is clients_full. The sample client page says the session is full
import { BitskiffHost, type Session } from '@bitskiff/host';

const session: Session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({ maxClients: 8 });
import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({ maxClients: 8 });

A new project holds 4 clients in a session opened at the default, and 40 clients across all its sessions at once. Those are the numbers to plan against until you raise something.

Two bounds sit over one session. The session's own maxClients is what you set on the open, and its ceiling is 500. The project's concurrentClients counts every client in every session at once, it is 40 by default, and a raised session meets it first: a session opened with maxClients: 100 still refuses the 41st client in the project with concurrency_limit.

The fifth client at a default session is refused by maxClients instead, so a demo with more devices than four sets maxClients on the open. Ask for a concurrentClients raise to match whatever capacity you set here; Raises says when one applies at once.

Other session settings

The other seven durations the open takes, each with its platform default and the ceiling a project may move it inside. Every one is a duration string—500ms, 90s, 5m, 1h30m: see Durations for the grammar and what a bad one does. A value above the ceiling is clamped, not refused.

A value that was not clamped comes back spelled the way you wrote it, and a clamped one comes back as the clamp in canonical form, because that value is the platform's and not yours. session.state.config is what you actually got.

SettingDefaultCeilingWhat it is
reconnectBudget10s2mhow long a client that dropped in the foreground keeps its place
backgroundBudget30s30mhow long a client whose page went to the background keeps its place
hostResumeWindow10s30mthe host grace: how long the session waits for its host, clients and join code intact. 0s ends the session with the host
idleTimeout0s, no idle limit24hthe idle limit: no client joined for this long ends the session
maxSessionTime0s, no limitnonethe longest a session may run
joinCodeRotation30s2mhow often the join code rotates, in whole seconds. The floor is 15s
warningLead30s5mhow long before a deadline each SDK raises its own warning

Durations

Every duration you write is a string: a session setting, a token's ttl, an SDK option, a CLI flag. One grammar covers all of them.

A duration is one or more counts, each with a unit and nothing between them:

JavaScript
import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({
  reconnectBudget: '20s',
  backgroundBudget: '2m',
  idleTimeout: '1h30m',
  maxSessionTime: '0s',
});

The rules:

  • The units are ms, s, m and h. There is no day unit, because a day's length depends on a calendar.
  • Units go from largest to smallest, and each appears at most once. 1h30m is a duration, 30m1h is not.
  • No spaces and no sign. 1h 30m and -5s are not durations.
  • A count may pass its unit's natural roll: 90s is a duration and means a minute and a half.
  • A count may be decimal as long as the whole duration lands on a whole number of milliseconds. 2.5s is a duration; 0.5ms is not.
  • Zero is written with a unit, like every other count: 0s. It is the off switch on the settings that have one.
  • A bare number is not a duration. There is no implied unit anywhere, so 90 is refused where 90s is accepted.
WrittenMeans
500mshalf a second
90sa minute and a half
5mfive minutes
1h30mninety minutes
0szero

What comes back. The api stores the string you sent and returns it on every read, so '90s' comes back '90s' and never '1m30s'. The one exception is a value the platform changed: a setting clamped to a ceiling comes back as the clamp, in canonical form, because that number is the platform's and not yours.

What a bad one does. A string this grammar refuses is a validation error whose details.reason is duration. It fails on the call, not later: idleTimeout: '90' and idleTimeout: '2d' are both refused at the open.

An SDK checks its own written options before the request goes out and names the option it read in details.option; the api refuses a bad duration it receives—a REST body, a CLI flag—and names the field in details.field.

JavaScript
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

try {
  await new BitskiffHost({ key: 'bsk_pk_...' }).open({ idleTimeout: '2d' });
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  // 'validation', then 'idleTimeout' and 'duration'.
  console.log(err.code, err.details?.option, err.details?.reason);
}

Readings are the other half of the rule and they are not strings. Anything the platform measured or counted down stays an integer of milliseconds and carries Ms in its name: retryAfterMs, rttMs. You write strings; you read numbers.

Quotas

A quota is a count against a ceiling. It refuses at the door and clears when something ends, when the period rolls, or when you raise it. It never cuts a live session or a client already in one: a project at its ceiling keeps serving every client already in a session, and stops opening the next one.

A session whose host never arrives holds a concurrentSessions slot for its hostResumeWindow, and never less than the 60 s life of the opener's ticket; it then ends host_resume_expired. So a backend that opens sessions ahead of their hosts can hold its whole ceiling on sessions with nothing attached.

LimitWhat it refusescodeWhat the live session seesWhat clears it
concurrentSessions, 5the next open()concurrency_limitnothinga session ends. retryAfterMs is 10000
concurrentClients, 40the next joinconcurrency_limitnothing; the client's join is refused and the sample client page counts downa client leaves or is booted. retryAfterMs is 10000
clientMinutesPerDay, 2000new opens and new joinsquota_exceededa capacity row at 80%, exceeded at 100%00:00 UTC, or a raise. resetAtUnixMs carries the instant
monthlySpendCap, 60000 rated unitsnew opens and new joinsquota_exceededa capacity row at 80%, exceeded at 100%the month rolls over, or a raise. A rated unit is a client-minute
samplePageSessions, 100 warn and 1000 refusejoins through the sample client page, past 1000 sessions counted for the life of the projectquota_exceededa capacity row past 100, exceeded past 1000; live sessions keep their clientspointing the project's joinUrl at a page of your own. No wait is given: details.setting is joinUrl
samplePageJoinsPerDay, 100joins through the sample client page, past 100 joins in one UTC dayquota_exceedednothing; the host learns of it only through a client's refused join00:00 UTC, or pointing the project's joinUrl at a page of your own
turnBytesPerMonth, 64 GiBthe relay credential for new direct links; joins and live links continueturn_quota_exceededa capacity row at 80%, exceeded at 100%; the host is told once per client, relayUnavailable on diagnosticthe month rolls over, or a raise

Relay is the fallback path a session takes when two peers cannot reach each other directly. A client on another network—cellular, a guest network—runs through it.

Rate limits

A rate limit is a count inside a window. It refuses the call in hand and nothing else, and the window is the whole of the wait: retryAfterMs is the time left in it.

Two rows below count tickets. A ticket is the single-use credential the service mints when a host or a client is about to connect; it expires in a minute and no SDK hands you one. Every open, every join and every resume mints one, so the ticket rows are the rows a reconnecting fleet meets.

LimitWhat it refusescodeWhat the live session seesWhat clears it
sessionOpensPerMinute, 10 a minute per project and per keyopen()rate_limitednothingthe window
sessionOpensPerMinuteIp, 30 a minute per IP addressopen() from one addressrate_limitednothingthe window. Ask for a raise to the number of hosts behind one address, up to 300
hostTicketsPerMinuteIp, 30 a minute per IP addressa host ticket mint from one address, which is every resume attemptrate_limitednothingthe window. Ask for a raise to the number of hosts behind one address, up to 300
joinsPerMinuteSession, 30 a minutea join to one sessionrate_limitednothingthe window
joinsPerMinuteKey, 300 a minutea join with one client key, across every session of its hostsrate_limitednothingthe window
joinsPerMinuteIp, 20 a minute per IP addressjoins from one IP addressrate_limitednothingthe window. Ask for a raise to the number of clients that join from one address in a minute, up to 200
joinsPerMinute, 6 a minute per subjecta join presenting a join tokenrate_limitednothingthe window. A token's row only, set on the mint; the project has no such setting
bootCooldown, 10m per subjectevery join as a subject the host booted for going over its byte rate, 8 KiB a second by default (see Channels), in every session of the project. A client booted this way ends with ended.reason rate, and the host sees clientLeft with raterate_limitednothingthe cooldown. retryAfterMs is the time left in it. Your own client.boot() never starts it, and a client that joined without a token has no subject to hold
badCodeAttempts, 10 per 600 sa join carrying a code or a join token that does not resolverate_limitednothingthe back-off, which doubles from 2000 ms. Not settable per project
clientResumesPerMinute, 6 a minuteone client's resume and leave callsrate_limitednothing; that client waits the window out and every other client keeps its own budgetthe window
presenceChangesPerMinute, 60 a minute per clientone client's presence changesrate_limitednothing; the change is not applied, and the SDK sends the latest presence again after the waitthe window. Not settable per project. A host applies at most one presence change a second, so a faster one shows the host nothing
statsReportsPerMinuteSession, 4 a minuteone session's stats reportsrate_limitednothing; the report is refused, not appliedthe window
ticketMintsPerMinute, 120 a minute per keya ticket mintrate_limitednothingthe window
tokensPerMinute, 120 a minute per keya token mintrate_limitednothingthe window. tokensOutstanding, 10000 live tokens, refuses the same way. The project's value is the ceiling each of its keys is held to, not a total across them
120 lossy messages a second, per client, across all lossy channels on one endnothing; the SDK drops the excess here rather than sending it late{ dropped: 'rate' } on the send, never an errora rateLimited diagnostic naming the channelthe next tick of the pace. See Channels
20 reliable messages a second, per sender, across all reliable channels on one endnothing at the sender; the receiving end enforces it and drops the excessrate_limited, from the receiving end at most once a secondnothing dropped locally and no rateLimited diagnostic; a host sees the reply on the session's error event, a client's SDK only logs it. A client that stays over the rate for ten seconds is closed with code 4100 and booted: the host gets clientLeft with booted, the client ends with booted when it tries to return, and the session carries onsending under the rate. See Channels

Refusals

Every refusal is a BitskiffError. Seven fields say everything you need. They are the SDK's fields: an error body over HTTP is a narrower thing, so the last column says where the same fact arrives there, for a backend that calls the REST routes itself.

FieldWhat it isOn the wire
codeconcurrency_limit, rate_limited or quota_exceededcode
retryabletrue for a concurrency or rate refusal, false for a cap. A cap's wait is a reset, not a retryretryable
clearswaiting when waiting alone can make the same call succeed, never when something else has to change first. A different fact from retryable: a spent cap is retryable: false and clears: 'waiting', because the period rollsnothing; the SDK derives it from the code and from where the answer came from
sourcewhere the refusal came from: api, this SDK with no call made (sdk), or the edge in front of api (edge)nothing; the SDK sets it itself
retryAfterMshow long to wait. The time left in the window for a rate limit, the time to the reset for a capretryAfterMs, and Retry-After in seconds
limitwhich limit refused: the LimitName spelling. A usage cap (quota_exceeded) sets it only for a secret key: on a publishable key it is undefined, and so is details.scope, because anyone can read a page's errors. The type covers every limit the platform has, more than the tables above name, so give a switch over it a default. Both SDKs export it: import type { LimitName } from '@bitskiff/host' (or '@bitskiff/client')details.limit, the same spelling. There is no top-level limit on the wire
details.scopewhere it was counted: project, key, subject, session, client, ip, org or address. ip is the address a join, an open or a host ticket came from; address is the console's own sign-in windows, which no SDK call meets; org is a ceiling the organization's plan holds across every project, or an invitation windowdetails.scope

A cap also carries resetAtUnixMs, the instant its period rolls. Render that as a date. Do not compute one from retryAfterMs, which was measured when the answer was written. resetAtUnixMs is the SDK's spelling: on the wire the same instant is details.resetAt, and only a cap carries one—a rate window has no reset and sends none.

Every value in a REST details object is a string, limit, scope and resetAt included, because that is what the wire type is. The SDK's fields are typed.

Waiting does not clear every refusal. A concurrency or rate refusal clears on its own, so the same call can simply be made again. A cap clears at the period roll or at a raise. The two sample-page bounds clear only by pointing the project's joinUrl at a page of your own.

The What clears it column above says which of the three a limit is, and a host that loops forever reads it to pick a wait.

import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const showAtCapacity = (until: number | undefined): void =>
  console.log(until === undefined ? 'At capacity' : `At capacity until ${new Date(until)}`);

async function openWhenThereIsRoom(): Promise<void> {
  try {
    const session = await bitskiff.open();
    console.log(session.joinCode.url);
  } catch (err) {
    if (!isBitskiffError(err)) throw err;
    switch (err.code) {
      case 'concurrency_limit':
        // A client's place frees on its own, so try the same call again after the wait it named.
        setTimeout(() => void openWhenThereIsRoom(), err.retryAfterMs ?? 10000);
        return;
      case 'quota_exceeded':
        // Not retryable: this one clears at the period roll or when somebody raises it.
        showAtCapacity(err.resetAtUnixMs);
        return;
      default:
        throw err;
    }
  }
}

await openWhenThereIsRoom();
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
// `until` is Unix ms, or undefined when no reset is known.
const showAtCapacity = (until) =>
  console.log(until === undefined ? 'At capacity' : `At capacity until ${new Date(until)}`);

async function openWhenThereIsRoom() {
  try {
    const session = await bitskiff.open();
    console.log(session.joinCode.url);
  } catch (err) {
    if (!isBitskiffError(err)) throw err;
    switch (err.code) {
      case 'concurrency_limit':
        // A client's place frees on its own, so try the same call again after the wait it named.
        setTimeout(() => void openWhenThereIsRoom(), err.retryAfterMs ?? 10000);
        return;
      case 'quota_exceeded':
        // Not retryable: this one clears at the period roll or when somebody raises it.
        showAtCapacity(err.resetAtUnixMs);
        return;
      default:
        throw err;
    }
  }
}

await openWhenThereIsRoom();

Write a default arm. A host that only knows the two codes above goes quiet on the third.

A page shows the time and nothing else. On a publishable key a spent cap tells the page when to come back, never which limit it was or by how much. A host on a secret key also reads err.limit, details.scope, details.value and details.ceiling, and can name the limit to its operator.

Warnings

You do not have to learn a cap exists by being refused by one.

Three counters fire a webhook: clientMinutesPerDay, monthlySpendCap and turnBytesPerMonth. Each sends limit.warning at 80 percent and limit.exceeded at 100. Each of the two fires once per limit, per scope, per period, so neither is a stream.

Every delivery carries the limit, its scope and scopeId, the value the counter reached, the ceiling it is measured against, requestId, the orgId, and keyId for a cap a key holds. Point a URL at your project to receive them; see Webhooks.

The console's Limits panel shows every limit with its effective value, what this period has spent and when that period resets. GET /v1/project/limits is the same numbers over the API, for a project that would rather graph them itself.

A live session's standing

A running host learns a limit as it nears, without waiting to be refused.

The session.capacity object is your project's standing against the limits its sessions spend. It is { status, limits, at }.

status is ok, warning or exceeded, the same three words the webhooks use. limits holds one row per limit that is not ok. Each row carries the limit, the scope it bound at, the scopeId, its own status, the value, the ceiling and the resetAt.

It is a list because a project can sit at warning on one limit while another is already exceeded. The top-level status is the worst row's.

The rows go to a host on a secret key. A host on a publishable key reads no row for a usage limit, because anyone can read a page: its standing stays ok with an empty list, and it learns a spent cap from the refusal's time.

The one row it can read is samplePageSessions, while the project still points at the sample client page. To watch a public page's limits, use the console or the webhooks above.

import { BitskiffHost, type CapacityNotice } from '@bitskiff/host';

const banner = document.getElementById('at-capacity');
if (!banner) throw new Error('missing element');

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

session.on('capacity', (capacity: CapacityNotice) => {
  banner.hidden = capacity.status === 'ok';
  banner.textContent = capacity.status === 'exceeded' ? 'At capacity' : 'Nearly at capacity';
});
import { BitskiffHost } from '@bitskiff/host';

const banner = document.getElementById('at-capacity');
if (!banner) throw new Error('missing element');

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

session.on('capacity', (capacity) => {
  banner.hidden = capacity.status === 'ok';
  banner.textContent = capacity.status === 'exceeded' ? 'At capacity' : 'Nearly at capacity';
});

The standing rides the answers the host already makes—the open response, and the traffic report it posts every 60 seconds—so a host learns inside a minute and nothing is pushed to it. The session.capacity object is a snapshot replaced whole, and an on('capacity') listener fires when the standing moves: a limit that stands for an hour is one event, and clearing is an event too.

A status of exceeded ends nothing. The session keeps its clients and its channels; only new opens and new joins are refused. A host that paints "at capacity" from this reads better than one that goes dark at its next refused open.

Raises

Some limits a project raises on itself. The rest are fixed, and a raise naming one of those is a validation refusal.

These are the raisable ones:

LimitWhat a raise buys
concurrentSessionsHow many sessions the project may have open at once
concurrentClientsHow many clients the project may have in sessions at once
sessionOpensPerMinuteHow fast the project, and each of its keys, may open sessions
joinsPerMinuteSessionHow fast clients may join one session
joinsPerMinuteIpHow fast clients may join from one address, to at most 200
joinsPerMinuteKeyHow fast clients may join with one client key, across its sessions
clientResumesPerMinuteHow fast one client may resume or leave
statsReportsPerMinuteSessionHow many traffic reports one session may post a minute
linkMessagesPerSecondThe lossy rate a channel may run at on a direct link
bytesPerSecondThe bytes one client may send a second, to the plan ceiling (16 KiB on experimental)
clientMinutesPerDayThe daily client-minute cap
monthlySpendCapThe monthly cap in rated units
turnBytesPerMonthThe monthly relay-byte cap
ticketMintsPerMinuteHow fast one key may mint tickets
tokensMaxTtlThe longest a token may live, to 720h (thirty days)
hostTicketsPerMinuteIpHow fast hosts behind one address may resume, to at most 300
sessionOpensPerMinuteIpHow fast one address may open sessions, to at most 300

Raise one in the console's Limits panel, or with POST /v1/project/limits/raise. There are two answers:

  • Up to your organization's plan ceiling, the raise applies at once and takes effect on the next open or join. The answer is applied: true.
  • Above the plan ceiling, the raise is held as a pending request and the limit keeps its value. The answer is applied: false with the plan's ceiling, and a limits.raise_requested event records it.

On the plan a new organization starts on, every raisable limit's ceiling is its default, except the byte row, whose ceiling is 16 KiB. So a new project's first raise of any other limit comes back applied: false. A raise naming a limit outside that table is refused validation, so write the error branch.

linkMessagesPerSecond is the ceiling a lossy channel's maxHz is clamped to on a direct link. It starts at the engine rate, 120 lossy messages a second (the lossy row under Rate limits), and never sits below it: lowering it under 120 does nothing. Declare a maxHz above 120 and, once raised, the channel runs at the lower of the two while a client's link is up.

A raise reaches a session's channels at its next open(). A channel reports the rate as linkMaxHz beside maxHz, and runs at maxHz again when the link drops; a host broadcast runs at linkMaxHz only while every client it reaches has one.

Lowering is always immediate and never ends live work. The count drains as sessions end.

bootCooldown is lowerable and not raisable, and its value is a duration. Lower it with bitskiff limits set bootCooldown 2m, or turn it off with 0s. GET /v1/project/limits reads it back as a duration string, and PUT /v1/project/limits takes one. A value above 10m is refused quota_above_ceiling.

The two sample-page bounds are fixed as well. The sample client page is there so your first host has something to be scanned into, and the way past samplePageSessions and samplePageJoinsPerDay is to point the project's joinUrl at a page of your own.

Per-key and per-subject limits

Every limit above is a project number, and a key or a token can carry a smaller one.

CredentialThe ceiling it carries
A publishable key shipped in a pagehalf the project's concurrentSessions, rounded down and never below 1, so it is 2 at the default 5
A secret key handed to a partnerone of its own, as tight as you set it
A join tokenone bound to a subject, so one signed-in person holds one session and one client however many tabs they open

The chain runs project, then key, then token, then the request, and the most restrictive value wins. A key may only tighten what the project set, and a request to loosen it is refused quota_above_ceiling rather than quietly clamped. The details.scope key on that refusal says which level bound it. Credentials has what each key, token and subject is allowed to do.

One limit is a word, not a number: whether a join must carry a join token.

LimitDefaultValues, least strict firstHow to set it
requireTokenoffoff, token, token_bound_to_sessionbitskiff limits set requireToken token, or add --key key_... for one key. bitskiff keys create and keys update take --limit requireToken=token

On POST /v1/project/limits/raise the word goes in word, not value: { "limit": "require_token", "word": "token" }, with keyId for one key. Any other word, or a number, is refused validation, and the message lists the three words.

token refuses every join that carries no join token holding sessions:join. The refusal is forbidden with details.reason of token_required. token_bound_to_session also refuses a token that does not name this session. A key may require more than the project and never less, and what a key requires reaches that key's sessions only.

Abuse

Channels carry your data. The control plane, the part of a session that says who is connected and how, is not a second way to send it. It is metered and bounded:

  • Every control frame a client sends, such as a hello, a ping or a presence change, is counted by kind. The counts show on the session's meters in the console. They are not billed.
  • A host applies at most one presence change per client per second. A client that flips between away and back faster than that shows its latest state once a second.
  • A hello is at most 8 KiB and a ping at most 256 bytes. A larger one is refused with payload_too_large.
  • Channel names are sealed when the host opens the session: at most 64 names, each at most 64 bytes.
  • Session metadata is set through the API only. A client cannot write it.

The per-second and byte figures are starting values and may change.

A project that uses the control plane to carry data is a terms matter, not a billing one.

Next steps