Budgets and limits
What your wisp may do is told, not discovered: ready carries its budget. A call past a
budget does nothing and answers limited, with wait, the milliseconds before the same call
can succeed. A wisp that paces itself from budget rarely sees one.
The budgets
Section titled “The budgets”"budget": {"calls":100,"burst":500,"serverCalls":50,"channelLines":5,"edits":4}| Key | Budget | Counted |
|---|---|---|
calls, burst |
calls on the line: 100 a second, in bursts of up to 500 | for each line on its own |
serverCalls |
calls about one server: 50 a second | for your whole wisp, every line together |
channelLines |
lines into one channel: 5 a second | for your whole wisp |
edits |
edits of one line, live frames included: 4 a second | for your whole wisp |
These are where Woosh starts, and they may change: read them from ready every time, and never
write them into your code. A line’s calls refill steadily and save up to burst.
serverCalls, channelLines and edits each count by the second, with no saving up. A call is
checked against every one of those three it spends before any is counted, so a call limited
there spends none of them. It has still spent one of the line’s calls, so a retry counts
there again.
What each call spends:
| Call | Spends, besides the line’s calls |
|---|---|
send |
serverCalls, channelLines |
respond, a line |
nothing more |
respond with edit |
the card’s edits only |
respond with private |
nothing more |
edit |
serverCalls, the line’s edits |
card.live |
serverCalls, the line’s edits (a private card: its edits only). Past it, limited, and nobody saw the frame |
delete, react, roles, kick, ban |
serverCalls |
history, members, member, tally, draw |
serverCalls |
activity |
one call every 15 seconds, the same words included; sooner answers limited with the wait |
An answer spends neither serverCalls nor channelLines: the person paid for the run with their
own budget, and a run has one answer, so answers come no faster than people ask.
And the people: each person may run commands and press wisps’ cards 3 times a second, across every wisp. Past that, their app tells them, and your wisp hears nothing.
How limited looks
Section titled “How limited looks”{"r":42,"e":"limited","w":"Past the line's calls a second","wait":8}{"r":13,"e":"limited","w":"Past the channel's lines or the server's calls a second","wait":200}Nothing was done. w is for your log, and changes; read e and wait. Send the same call
again after wait (a send under the same sendId, so it can never post twice).
Pace, do not bounce
Section titled “Pace, do not bounce”When one call comes back limited, the second it hit is full, and every call in flight behind
it into the same server or channel will be limited too. Retrying each after its own wait
sends them all again at the same moment, into the next second, and most bounce again. Instead,
keep the budgets yourself and wait before you call. serverCalls, channelLines and edits
each allow so many calls in each one-second window, so a note of when each call goes is all it takes;
the line’s calls is a bucket that refills:
const SECOND = 1100; // one second as Woosh counts it, and a little more for the trip there
/** serverCalls, channelLines or edits: at most `limit` calls in each one-second window. */class Window { constructor(limit) { this.limit = limit; this.times = []; // when its calls go, from a second ago on } /** The earliest moment from `at` on with room for one more call. */ free(at) { const crowd = (t) => this.times.filter((x) => x > t - SECOND && x < t + SECOND).length; const tries = [at, ...this.times.map((x) => x + SECOND).filter((t) => t > at)].sort((a, b) => a - b); return tries.find((t) => crowd(t) < this.limit); } book(at) { const now = performance.now(); this.times = this.times.filter((x) => x > now - SECOND).concat(at); }}
/** A line's calls: `rate` a second, up to `burst` saved. */class Bucket { constructor(rate, burst) { Object.assign(this, { rate, burst, tokens: burst, at: performance.now() }); } /** The earliest moment from `at` on with a call to spend. */ free(at) { const tokens = Math.min(this.burst, this.tokens + ((at - this.at) / 1000) * this.rate); return tokens >= 1 ? at : at + ((1 - tokens) / this.rate) * 1000; } book() { const now = performance.now(); this.tokens = Math.min(this.burst, this.tokens + ((now - this.at) / 1000) * this.rate) - 1; this.at = now; }}
let budget; // from readylet line; // new Bucket(budget.calls, budget.burst), at each readyconst windows = new Map(); // "server:<id>", "channel:<id>", "edits:<id>" -> its Window
function kept(key, limit) { if (!windows.has(key)) windows.set(key, new Window(limit)); return windows.get(key);}
const inServer = (id) => kept(`server:${id}`, budget.serverCalls);const inChannel = (id) => kept(`channel:${id}`, budget.channelLines);const editsOf = (id) => kept(`edits:${id}`, budget.edits);
/** Books one call on the line and in each budget it spends; the milliseconds to wait before sending it. */function slot(...spends) { const now = performance.now(); const all = [line, ...spends]; let at = now; while (all.some((b) => b.free(at) > at)) at = Math.max(...all.map((b) => b.free(at))); for (const b of all) b.book(at); return at - now;}
const sleep = (ms) => new Promise((go) => setTimeout(go, ms));
// a line: await sleep(slot(inServer(d.server), inChannel(d.channel))); then call("send", …)// an edit, or a frame: await sleep(slot(inServer(d.server), editsOf(message)));// a read, a reaction: await sleep(slot(inServer(d.server)));// an answer to a run: await sleep(slot());A wisp that waits as its windows say stays inside every budget, and a call that waits for one
channel holds back no call into another. The budgets other than calls are your whole wisp’s,
so share one set of windows between the lines of one process. Keep the limited retry from
The line as the fallback, for the call your windows could
not foresee: one from your other process during a hand-over, say.
Edits: keep the newest
Section titled “Edits: keep the newest”A card that changes faster than 4 times a second does not need every change shown. Keep the
newest change for each line, and send it when the line’s edits allow; anything in between is
dropped by you, before it costs anything:
const newest = new Map(); // line id -> the latest card waiting
function show(server, message, card) { const scheduled = newest.has(message); newest.set(message, card); if (scheduled) return; // the frame on its way takes this card setTimeout(() => { const latest = newest.get(message); newest.delete(message); call("card.live", { message, card: latest }); // answered like any call }, slot(inServer(server), editsOf(message)));}Better still, let the app do the moving: an animated picture costs no edits at all, and a time mark counts down by itself.
Every number
Section titled “Every number”| What | Limit |
|---|---|
| Calls on a line | 100 a second, bursts of 500 (budget) |
| Calls about one server | 50 a second, for your whole wisp (budget) |
| Lines into one channel | 5 a second (budget) |
| Edits and frames of one line | 4 a second (budget) |
| A person’s runs and presses | 3 a second, across every wisp |
| Your wisp’s words | one activity call every 15 seconds; they last two minutes |
| Calls in flight on a line | 32 |
| A call’s answer | within 10 seconds, or failed |
| Answering a run or a press | once, within 15 seconds |
| A message you send | one frame, 64 KB |
| The hello | within 10 seconds of the socket opening |
| Silence before a line is dead | 30 seconds (silence) |
| The replay window | 10 minutes (replay: 600) |
| The group’s log | its last 16,384 events, kept 10 minutes past the newest |
| A replay | 4 MB; past it, the group is in g |
| A line’s unread backlog | 8 MB, then it is dropped |
| A line’s age | asked to move at 5 to 6 days |
| A move | moved within 2 minutes, or the old line is closed with 1012 |
| Lines open at once | twice your slices |
| Slices | 1 to 64 |
| Wisps one person makes | 25 |
| A wisp’s description | 400 characters |
| The token | an hour; 120 token requests a minute from one address |
| Commands | 50 a wisp; 10 options a command; 1 to 25 choices |
| A command or option’s name | 1 to 32 lowercase letters, digits and -, starting with a letter or a digit |
| A text option | 1 to 1,000 characters |
| The text of a posted line | 4,000 characters |
| A card | 40 parts, 4,000 characters of text, 8 KB |
| An action’s name, a menu’s value, a key | 1 to 32 of a to z, 0 to 9, _ and -, starting with a letter or a digit |
| An action’s or a cell’s value | 1 to 100 bytes |
| A menu | 1 to 25 options |
| A channel’s history | 100 lines a page |
| A server’s members | 1,000 a page |
| A tally’s list | 1,000 a page |
| A draw | 1 to 100 people |
| Pictures | PNG or APNG, JPEG, WebP or GIF; 2 MB; two uploads a second |
| Servers before review, for a wisp that reads messages | 100 |
© 2026 Woosh AS. All rights reserved.