Max connections: what happens when a portal runs out of slots
The cap is counted on the source. Your device can read the number, spend against it, and never once check whether it moved.
"max_connections": "2",
"active_cons": "1"
"max_connections": 2,
"active_cons": 1
Two panel builds, one endpoint, the same two fields, and no agreement on whether the values are strings or numbers. The second block is lifted from a fixture, values and all. The first is assembled: the strings-shaped payload we keep carries a max_connections of "1" and no active_cons at all, so that exact pair appears in no file we hold. The variance is real either way. Our data transfer object types both fields as a nullable string behind a converter that reads a JSON string, a JSON number or a boolean, because one strict field fails the whole handshake, and a source whose handshake fails has no channels at all.
The first of those numbers decides whether your next channel change opens. The second is a live count of what your account is holding right now, and it reaches no screen in our app. Both facts are load-bearing. The second one is mine to answer for.
A slot lives on the source, not in the player
max_connections is the account's ceiling on simultaneous streams. The panel API documents it on the same object as active_cons, the connections active now. The ceiling is kept on the source and, on a build that enforces it correctly, applied there. Whether a given build enforces it at all, or counts two streams from one address as one, no client can test.
What our app does not do is watch the number. The portal will tell you: active_cons rides the same response as the cap, and a client that re-polled it could watch it move. We read it once, when the server imports your source or when a later sync re-runs the handshake, and we never look again. Everything below about when a source frees a slot is an inference from our side of the socket. We know exactly when we stop reading. When the counter at the other end goes back down, we have never measured.
Inside our Windows app, six things open a stream and spend against that ceiling:
- the live player showing the channel you are on
- a movie, episode or catch-up programme, which draws on the same account and the same cap as live
- each tile of a multiview layout, which gets its own decoder instance and its own player
- a running recording
- the standby decoder during a failover overlap, for a few seconds
- the live player again after a picture-in-picture toggle, which tears the pipeline down and rebuilds it
Catalog synchronization and guide downloads are requests that end. Whether a source counts those against the same ceiling is its business, not something we can observe.
The number arrives as a string, as a number, or not at all
Three max_connections shapes have fixtures. "max_connections": "2" is the spelling the panel API references write down. "max_connections": 2 is a bare number, and even that build is not consistent: in the numbers-shaped payload we keep, the expiry and creation stamps are bare integers while timestamp_now is still quoted. And "user_info": [], an array where the object belongs, nulls the entire user block and takes both connection fields down with it while the rest of the import proceeds.
A fourth shape is reachable and unpinned. The same converter maps a bare false to absent, not to zero, which we hold on record only for a URL field, where a "0" would be worse than nothing. max_connections carries that converter, so a boolean there vanishes the same way. No fixture proves it, and I would rather flag that than let three measured shapes pass for four.
Whatever survives is coerced with an invariant-culture integer parse, and a value that fails omits the key from the stored metadata instead of writing a fallback into it. Absent and one are two different states on the wire, right up until the device reads them.
At the device they collapse into one state. Anything not greater than zero normalizes to 1, and a source id the cap cache has never heard of also reads 1. Non-portal kinds always take that default, because the field only exists on the portal handshake. Across the 2,203 entries in the two real playlists we measure, the EXTINF attribute surface is eight keys, none a connection count, and neither RFC 8216 nor the reference every player borrows from defines one.
So a source whose real cap is eight, imported on an afternoon when its portal was timing out, is a one-connection source to us until a later sync merges the metadata: the import wraps that handshake in a best-effort catch, on the reasoning that the credentials may be fine even when the metadata call is not. One is the conservative direction, and every consequence of guessing wrong is a feature declining to act, never a stream being killed.
A switch is a release followed by an acquire, in that order
Changing channel runs one function behind a per-player lock: stop the player, detach the media, dispose it, construct the replacement, play. The order is not incidental. The lock exists because two applies racing on one player could interleave so that the stale one ran its stop-and-clear prefix after the newer one had already started its stream, killing the stream the user asked for. The stale apply then finds itself superseded and returns before building anything, so what survives is not the old channel. It is a black rectangle.
Stop is the only part of a channel change that can take seconds.
It is a synchronous native call and on a dead or reconnecting channel it blocks, which is why it runs on a pool thread and never on the thread drawing your interface. That is good for the interface and it is exactly what makes a cap feel smaller than it is. The app has moved on. The socket has not necessarily closed, and the source's counter has certainly not been consulted.
Teardown when you leave a page or close the app is looser still. Retiring a player is fire and forget: stop, clear, dispose, all on a pool thread, with nothing waiting on the result. Two paths bound the wait. App exit allows 1 second; a player that failed partway through being built gets 2. If the bounded path cannot take the lock in its window it hands the teardown back to the unbounded one and returns anyway, because releasing a player underneath a thread still inside the native library takes the process down. Both figures are compiled constants and will move if anyone measures them. Closing the app is a request to release, not proof the release finished.
Picture-in-picture is the surprising one. Moving the video canvas between windows requires a full teardown and re-bootstrap of the decoder, so each toggle closes the stream and opens it again. Two toggles are two reconnects on a cap that only ever had room for one.
The arithmetic an overlapping switch has to pass
Most switches need one slot at a time. One case genuinely needs two: a stream fails, often without the engine reporting anything but Playing, and the player brings up a replacement on a second decoder so the picture never goes black. During that overlap both streams are open. The rule that gates it, copied out of the planner that owns the decision:
required = 1 (standby)
+ 1 when the candidate streams from the same source as the outgoing stream
+ active recordings on the incoming source
available = max_connections of the incoming source
Line one is the replacement stream, which is what the overlap buys. Line two is the outgoing stream, counted only when it draws on the same source entry. Line three is every recording already running on the incoming source, which is how the overlap is stopped from taking a slot a recording holds. The comparison is against the incoming source alone, because the outgoing source keeps its connection and gives it back when the swap commits.
Three consequences fall out, and all three are pinned by test. A one-connection source can overlap across sources, the ordinary aggregated-source failover. That test compares configured source ids, not credentials, so if two of your sources are the same portal account entered twice the planner cannot tell, and the overlap spends two slots on one cap. A one-connection source can never overlap within itself, so a same-source alternate degrades to the visible restart that shipped before this existed. And required 3 against available 2, a same-source overlap on a source already recording, refuses. Refusing is the safe direction. Tripping the cap would drop the stream the viewer is still watching, which is worse than the restart the overlap was meant to avoid.
The window is bounded on our side. Current tuning gives the standby 6,000 ms to reach its first frame and 250 ms to settle before the swap commits, so we hold the second decoder open for 6,250 ms at the outside and abandon a standby that misses the deadline. When the source stops counting that connection is a separate question, not one we can answer. Those are tuning numbers and will move; the arithmetic is the part that carries the meaning. The connection-budget, planner, cap-cache, arbitrator and recording-concurrency tests run 57 cases and all pass as of this writing.
A recording takes the connection the picture was using
Recording capacity is the cap minus one, so a three-connection source can run two recordings and still leave a slot for you to watch something. A one-connection source is a special case in the code and a blunt one in practice: capacity stays at one, and starting a recording suspends playback for that source, exits picture-in-picture and replaces the video with a card headlined "Preview paused while recording". Nothing is stolen and nothing is queued behind a stall. The recording gets the slot and the screen says so, in copy written separately for a live channel, a movie and an episode.
Above that sits a global setting for concurrent jobs, defaulting to 2, and the effective per-source cap is the smaller of the two. At a cap of three the two limits tie. At four and above, the app's own default is the binding constraint.
A scheduled job locks its guide window into a fixed UTC start and end the moment you book it, and nothing re-resolves it afterwards. If the guide was an hour out when you booked, the slot is spent an hour off the program you wanted. If the guide moves later, the job stays put and records the wrong content silently. The connection math cannot notice either case, because from where it sits a slot was requested and a slot was used.
Nothing refuses a scheduled recording on cap grounds at all. The math is there and the live-playback preemption uses it, but you can book more overlapping recordings on one source than its cap can carry, and the surplus jobs fail at the source when they start, with no warning when you scheduled them. Our error taxonomy carries a code for that refusal and records it as partially backed: the math exists, the scheduling check does not.
Nine tiles need nine connections only if they share one source
Each tile builds its own decoder instance and its own player, and the six shipped geometries hold 2, 4, 3, 4, 6 and 9 tiles in declaration order. A filled tile costs one connection from its own source, so nine tiles need nine connections only when all nine draw on one account; across three sources they cost three apiece, and an empty slot costs nothing. Tiles opening together spread over a shared 180 ms schedule, which puts the ninth tile of a 3x3 layout 1,440 ms behind the first, that spacing being another compiled constant.
The source picker counts assigned tiles per source and deliberately excludes the slot you are editing, so changing a tile's channel frees that tile's connection before the replacement is tested. Rows read "2 of 4 connections in this layout", or mark the source full, and a layout with no room anywhere gets a blocked state, not a failure. The sources list explains the same number more quietly, as a badge reading "1 connection" or "4 connections" with a tooltip naming it the maximum simultaneous stream connections the portal allows.
The worst bug this feature has shipped had nothing to do with connections and everything to do with those nine tiles. The server-side validator for synced layouts carried a geometry allowlist never extended past four shapes, while both clients were already writing 2x3 and 3x3, so saving a six or nine tile layout produced a payload the server refused. The symptom was not a failed layout save, which would have been findable. Sync ships changes in batches of up to 500 and one rejected record fails the whole batch, so an unrelated favorite toggled in the same window went missing too, and the person who lost it had no reason to connect it to a layout built minutes earlier. The comment over the fixed allowlist now says the rule out loud: adding a geometry to the client means adding it here, in the same change.
The player cannot tell you the limit was the reason
libvlc knows the HTTP status code. It parses the code and branches on it, but never puts it on the event surface our player is built against, and our log forwarder filters on a nine-fragment allowlist of pools, decoders and surfaces with no http in it. So a refusal on connection grounds, an expired account, a mistyped host and a dead channel reach us as one event. That is our plumbing, not the protocol, and our taxonomy tells documentation not to promise the status, so this page does not.
One refusal we do know, because we computed it: the overlap that found no spare connection. It names a cause a person could act on and reaches no surface, which our own note concedes. Every surface here is Windows desktop; the Xbox head has no recording code and none of these cards.
We read active_cons and show it to nobody
active_cons is parsed by the same tolerant converter as the cap, coerced to an integer, written into the stored metadata by the server that ran the handshake, carried on the wire to the desktop app and typed in the web console. No screen renders it. A search across the web app, the client source and the string catalogs finds it only in the types and mappers that carry it along, never in a view or a line of copy. The budget snapshot has a matching dead field, a flag for whether playback holds a connection, which production code only ever sets false.
That one is my mistake, not an oversight I inherited, and it annoys me every time I look at the type.
There is a defensible version of the decision. The value would lie. It is captured during a handshake and stamped with the moment of capture, so any figure we drew would be a reading from the last sync, not a live count, and the cap cache refreshes from that same stored snapshot rather than from a fresh call to your portal. A stale "3 of 4 in use" is worse than no number at all, especially on the screen people open when they are already hunting for a cause. That argument is real. It is also not the argument that produced the current state, since the field was carried all the way to the wire and then left there.
Our Windows app does this arithmetic on your machine, against the cap your source reported during its own handshake. The server keeps that one number and no stream passes through us at any point. You can create a free account and try it on a source you already have.
Count your own openers before you blame the source. The app sees only the streams it opened itself, so a second device left on a channel in another room is a spend it can never account for. When your player fails while that account plays fine on the second device, the cap is the first thing to suspect, and no error message will suspect it for you.
What this article measured38 claims, each with the evidence behind it
| Claim | Evidence | Counted |
|---|---|---|
| The panel API documents max_connections on the user_info object as the account's maximum simultaneous connections, and active_cons as the connections active now.Xtream Codes player_api.php, user_info object field reference (max_connections, active_cons) | Specification | Not applicable |
| active_cons is documented as a now figure, returned on the same player_api.php response that carries the cap, so a client that re-polled the endpoint could watch the count move.Xtream Codes player_api.php, user_info object field reference (active_cons, Active Connections (Now)) | Specification | Not applicable |
| No playlist attribute carries a connection count. RFC 8216 defines no such tag, and the IPTV Simple playlist attribute reference documents none.RFC 8216 section 4.3; kodi-pvr/pvr.iptvsimple README, playlist attribute reference | Specification | Not applicable |
| max_connections and active_cons arrive as JSON strings on some panel builds and bare JSON numbers on others, so both are typed as nullable strings behind a tolerant converter. | n = 2 | Aug 23, 2026 |
| The numbers-shaped payload is not uniformly numeric: exp_date and created_at are bare integers while timestamp_now is a quoted string and allowed_output_formats mixes strings with an integer. | n = 1 | Aug 23, 2026 |
| A boolean in a text field maps to absent and not to 0 or 1, because a "0" would poison URL consumers sharing the same converter. This is pinned for a URL field only; max_connections carries the same converter, so the behavior follows by construction but no fixture exercises it. | n = 1 | Aug 23, 2026 |
| A user_info emitted as a JSON array yields a null user block, so both connection fields vanish and the source still imports. | n = 1 | Aug 23, 2026 |
| The string is coerced with int.TryParse under NumberStyles.Integer and the invariant culture; a value that fails omits the key from the stored metadata and writes no default. | n = 1 | Aug 23, 2026 |
| Anything not greater than zero normalizes to 1, and an unresolved source id also reads 1. Non-Xtream source kinds always take the default. | n = 6 | Aug 23, 2026 |
| Across the two real corpus playlists the union of EXTINF attribute keys is eight, and none of them is a connection count. | n = 2203 | Aug 23, 2026 |
| The metadata handshake at import is wrapped in a best-effort catch, so a portal that times out leaves the field absent until a later sync merges it. | n = 1 | Aug 23, 2026 |
| A channel change stops the player, clears and disposes the media, then constructs the replacement media and plays it, all behind one per-player gate. Stop is synchronous and can block for a long time on a dead or reconnecting channel, so it runs on a pool thread. | n = 1 | Aug 23, 2026 |
| The race the gate prevents kills the newer stream. The stale apply then finds itself superseded and returns before it constructs any media, so nothing is left playing, not even the outgoing channel. | n = 1 | Aug 23, 2026 |
| Retiring a player runs Stop, media clear and Dispose on a pool thread and never blocks the caller. Two bounded paths exist: 1 second on app exit, and 2 seconds when tearing down a player that failed partway through being built. On timeout the bounded path hands teardown back to the unbounded pool path and returns. | n = 2 | Aug 23, 2026 |
| Entering picture-in-picture reparents the video canvas across XamlRoots, which requires a full libvlc teardown and swapchain re-bootstrap, so the stream is closed and reopened. | n = 1 | Aug 23, 2026 |
| A movie or episode player is a distinct playback context on the same engine and the same account, so it spends against the same cap as live; the recording block card ships separate copy for live, movie and show. | n = 3 | Aug 23, 2026 |
| The overlap gate computes required = 1 standby + 1 when the candidate is on the same source as the outgoing stream + active recordings on the incoming source, against available = that source's max_connections. | n = 1 | Aug 23, 2026 |
| The same-source test is an ordinal comparison of configured content source ids, not of credentials, so one portal account entered as two sources reads as two sources against one real cap. | n = 1 | Aug 23, 2026 |
| A one-connection source can overlap across sources but never within one source, where it degrades to the in-place restart; a recording on the incoming source is counted, so an overlap can never take the connection a recording holds. Required 3 against available 2 is the pinned refusal. | n = 3 | Aug 23, 2026 |
| Current tuning bounds OUR side of the overlap at a 6000 ms prepare timeout plus a 250 ms commit settle, and the shipped settings file turns the feature on. It says nothing about when the source stops counting the connection. | n = 1 | Aug 23, 2026 |
| The connection-budget, handover-planner, limit-provider, arbitrator and per-source recording concurrency tests run 57 cases with 0 failures. | n = 57 | Aug 23, 2026 |
| Recording slot capacity is max_connections minus one, reserving one connection for playback; at a cap of one the capacity is one and starting a recording blocks playback on that source instead. | n = 4 | Aug 23, 2026 |
| On a one-connection source the arbitrator suspends playback for the source, exits picture-in-picture and replaces the video with a card headlined "Preview paused while recording". | n = 1 | Aug 23, 2026 |
| The effective per-source recording cap is the smaller of the global concurrent-jobs setting, which defaults to 2, and the source's own capacity. | n = 1 | Aug 23, 2026 |
| A scheduled job carries a fixed UTC start and end and nothing re-resolves it after later guide imports, so a guide wrong at booking time spends the slot on the wrong hour, and a guide that moves afterwards leaves the job recording the wrong content silently. | n = 1 | Aug 23, 2026 |
| Nothing refuses a scheduled recording on connection-cap grounds today. The taxonomy records the code as partially backed: the budget math exists, the scheduling refusal does not. | n = 1 | Aug 23, 2026 |
| Each multiview tile constructs its own libvlc instance and its own player. Six geometries ship, holding 2, 4, 3, 4, 6 and 9 tiles in declaration order. | n = 6 | Aug 23, 2026 |
| A tile costs one connection from its own source and an unassigned slot costs nothing, so nine tiles need nine connections only when all nine draw on the same source. | n = 1 | Aug 23, 2026 |
| Tiles opening together spread over a shared 180 ms schedule, so the ninth tile of a 3x3 layout calls play 1,440 ms after the first. The spacing is a compiled constant and can move. | n = 1 | Aug 23, 2026 |
| The layout budget counts one connection per assigned tile per source and excludes the slot being edited, so changing a tile's channel frees that tile's connection before the new one is tested. | n = 1 | Aug 23, 2026 |
| The account-sync validator's geometry allowlist was missing 2x3 and 3x3 while the clients already wrote them, so saving a 6- or 9-tile layout produced a payload the server rejected, and one rejected record fails the whole batch. | n = 1 | Aug 23, 2026 |
| libvlc parses the HTTP status code and branches on it, but the MediaPlayer event surface the client is built against does not carry it, and the optional libvlc log forwarder filters on a nine-fragment allowlist that has no http entry, so the code never reaches our logs even when the capture is enabled. | n = 1 | Aug 23, 2026 |
| The one refusal reason that names a user-fixable cause, the incoming source having no spare connection, has a taxonomy code and no surface, and the taxonomy records surfacing it as a genuine product improvement. | n = 1 | Aug 23, 2026 |
| active_cons is coerced, stored, carried on the wire to both clients and typed in the web console, and no surface renders it. PlaybackReserved on the budget snapshot is likewise never set true outside tests. | n = 2 | Aug 23, 2026 |
| The stored metadata block carries a capturedAtUtc, so every connection figure the app holds is a reading from the last handshake and not a live count. | n = 1 | Aug 23, 2026 |
| The client's cap cache refreshes from the catalog's stored snapshot, never from a fresh call to the portal. Four production call sites refresh it; its Invalidate method is never called outside tests. | n = 4 | Aug 23, 2026 |
| Every connection-aware surface described here is Windows desktop only. The generic stream-refusal code is the one entry in this set that also lists the Xbox head. | n = 4 | Aug 23, 2026 |
| The sources list shows the cap as a badge reading "1 connection" or "N connections", only when the value is greater than zero, with a tooltip naming it the maximum simultaneous stream connections the portal allows. | n = 1 | Aug 23, 2026 |