Limits and capacity
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.
| Setting | Default | Ceiling | How to set it | What a client sees when it is full |
|---|---|---|---|---|
maxClients | 4 | 500 | open({ 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.
| Setting | Default | Ceiling | What it is |
|---|---|---|---|
reconnectBudget | 10s | 2m | how long a client that dropped in the foreground keeps its place |
backgroundBudget | 30s | 30m | how long a client whose page went to the background keeps its place |
hostResumeWindow | 10s | 30m | the host grace: how long the session waits for its host, clients and join code intact. 0s ends the session with the host |
idleTimeout | 0s, no idle limit | 24h | the idle limit: no client joined for this long ends the session |
maxSessionTime | 0s, no limit | none | the longest a session may run |
joinCodeRotation | 30s | 2m | how often the join code rotates, in whole seconds. The floor is 15s |
warningLead | 30s | 5m | how 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:
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,mandh. 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.
1h30mis a duration,30m1his not. - No spaces and no sign.
1h 30mand-5sare not durations. - A count may pass its unit's natural roll:
90sis 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.5sis a duration;0.5msis 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
90is refused where90sis accepted.
| Written | Means |
|---|---|
500ms | half a second |
90s | a minute and a half |
5m | five minutes |
1h30m | ninety minutes |
0s | zero |
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.
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.
| Limit | What it refuses | code | What the live session sees | What clears it |
|---|---|---|---|---|
concurrentSessions, 5 | the next open() | concurrency_limit | nothing | a session ends. retryAfterMs is 10000 |
concurrentClients, 40 | the next join | concurrency_limit | nothing; the client's join is refused and the sample client page counts down | a client leaves or is booted. retryAfterMs is 10000 |
clientMinutesPerDay, 2000 | new opens and new joins | quota_exceeded | a capacity row at 80%, exceeded at 100% | 00:00 UTC, or a raise. resetAtUnixMs carries the instant |
monthlySpendCap, 60000 rated units | new opens and new joins | quota_exceeded | a 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 refuse | joins through the sample client page, past 1000 sessions counted for the life of the project | quota_exceeded | a capacity row past 100, exceeded past 1000; live sessions keep their clients | pointing the project's joinUrl at a page of your own. No wait is given: details.setting is joinUrl |
samplePageJoinsPerDay, 100 | joins through the sample client page, past 100 joins in one UTC day | quota_exceeded | nothing; the host learns of it only through a client's refused join | 00:00 UTC, or pointing the project's joinUrl at a page of your own |
turnBytesPerMonth, 64 GiB | the relay credential for new direct links; joins and live links continue | turn_quota_exceeded | a capacity row at 80%, exceeded at 100%; the host is told once per client, relayUnavailable on diagnostic | the 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.
| Limit | What it refuses | code | What the live session sees | What clears it |
|---|---|---|---|---|
sessionOpensPerMinute, 10 a minute per project and per key | open() | rate_limited | nothing | the window |
sessionOpensPerMinuteIp, 30 a minute per IP address | open() from one address | rate_limited | nothing | the window. Ask for a raise to the number of hosts behind one address, up to 300 |
hostTicketsPerMinuteIp, 30 a minute per IP address | a host ticket mint from one address, which is every resume attempt | rate_limited | nothing | the window. Ask for a raise to the number of hosts behind one address, up to 300 |
joinsPerMinuteSession, 30 a minute | a join to one session | rate_limited | nothing | the window |
joinsPerMinuteKey, 300 a minute | a join with one client key, across every session of its hosts | rate_limited | nothing | the window |
joinsPerMinuteIp, 20 a minute per IP address | joins from one IP address | rate_limited | nothing | the 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 subject | a join presenting a join token | rate_limited | nothing | the window. A token's row only, set on the mint; the project has no such setting |
bootCooldown, 10m per subject | every 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 rate | rate_limited | nothing | the 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 s | a join carrying a code or a join token that does not resolve | rate_limited | nothing | the back-off, which doubles from 2000 ms. Not settable per project |
clientResumesPerMinute, 6 a minute | one client's resume and leave calls | rate_limited | nothing; that client waits the window out and every other client keeps its own budget | the window |
presenceChangesPerMinute, 60 a minute per client | one client's presence changes | rate_limited | nothing; the change is not applied, and the SDK sends the latest presence again after the wait | the 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 minute | one session's stats reports | rate_limited | nothing; the report is refused, not applied | the window |
ticketMintsPerMinute, 120 a minute per key | a ticket mint | rate_limited | nothing | the window |
tokensPerMinute, 120 a minute per key | a token mint | rate_limited | nothing | the 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 end | nothing; the SDK drops the excess here rather than sending it late | { dropped: 'rate' } on the send, never an error | a rateLimited diagnostic naming the channel | the next tick of the pace. See Channels |
| 20 reliable messages a second, per sender, across all reliable channels on one end | nothing at the sender; the receiving end enforces it and drops the excess | rate_limited, from the receiving end at most once a second | nothing 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 on | sending 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.
| Field | What it is | On the wire |
|---|---|---|
code | concurrency_limit, rate_limited or quota_exceeded | code |
retryable | true for a concurrency or rate refusal, false for a cap. A cap's wait is a reset, not a retry | retryable |
clears | waiting 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 rolls | nothing; the SDK derives it from the code and from where the answer came from |
source | where 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 |
retryAfterMs | how long to wait. The time left in the window for a rate limit, the time to the reset for a cap | retryAfterMs, and Retry-After in seconds |
limit | which 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.scope | where 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 window | details.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:
| Limit | What a raise buys |
|---|---|
concurrentSessions | How many sessions the project may have open at once |
concurrentClients | How many clients the project may have in sessions at once |
sessionOpensPerMinute | How fast the project, and each of its keys, may open sessions |
joinsPerMinuteSession | How fast clients may join one session |
joinsPerMinuteIp | How fast clients may join from one address, to at most 200 |
joinsPerMinuteKey | How fast clients may join with one client key, across its sessions |
clientResumesPerMinute | How fast one client may resume or leave |
statsReportsPerMinuteSession | How many traffic reports one session may post a minute |
linkMessagesPerSecond | The lossy rate a channel may run at on a direct link |
bytesPerSecond | The bytes one client may send a second, to the plan ceiling (16 KiB on experimental) |
clientMinutesPerDay | The daily client-minute cap |
monthlySpendCap | The monthly cap in rated units |
turnBytesPerMonth | The monthly relay-byte cap |
ticketMintsPerMinute | How fast one key may mint tickets |
tokensMaxTtl | The longest a token may live, to 720h (thirty days) |
hostTicketsPerMinuteIp | How fast hosts behind one address may resume, to at most 300 |
sessionOpensPerMinuteIp | How 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: falsewith the plan'sceiling, and alimits.raise_requestedevent 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.
| Credential | The ceiling it carries |
|---|---|
| A publishable key shipped in a page | half the project's concurrentSessions, rounded down and never below 1, so it is 2 at the default 5 |
| A secret key handed to a partner | one of its own, as tight as you set it |
| A join token | one 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.
| Limit | Default | Values, least strict first | How to set it |
|---|---|---|---|
requireToken | off | off, token, token_bound_to_session | bitskiff 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
- Webhooks
- Channels
- Session lifecycle
- Unattended hosts, for a host that reopens by itself after a session ends