Protocol
Limits and errors
Widget extensions have strict resource limits. The app enforces all of the limits below; responses that exceed them are rejected.
| Limit | Value | Notes |
|---|---|---|
| JSON body | 256 KB | Measured before decoding. A larger response is rejected without being parsed. |
| Image body | 2 MB | Applies to `image` nodes and to bitmap responses alike. |
| Image pixels | 2000 px | On the longest side. |
| Nodes | 300 | Counted across the whole tree, including every entry. |
| Depth | 12 | How far stacks may nest. |
| Timeline entries | 60 | Extra entries are dropped, not an error. |
| Entry spacing | 5 minutes | Entries closer together are dropped, because WidgetKit ignores them. |
| Refresh | 300–86400 s | Values outside the range are clamped, not rejected. |
| Request timeout | 10 s | |
| Redirects | 3 | HTTPS is re-checked at every hop. |
Invalid payloads
Decode failures produce a specific message rather than a generic one. When there is no cached response to fall back on, the widget draws a full face with the message under a small “Couldn't load” heading. That is what you see below: the widget's failure face, with the wording coming live from the same engine as every other preview on this site.
{"v": 1, "view": {"type": "Marquee"}} {"v": 1, "view": {"type": "text"}} {"v": 1, "view": {"type": "text", "text": "x", "color": "3DDC84"}} {"v": 1, "view": {"type": "image", "url": "file:///etc/passwd"}} Decode errors
These cases come from web/conformance/decode-cases.json, a corpus executed by
both test suites — the Swift decoder in the app and the Rust engine that draws the previews
here. A message documented on this page is a message both implementations are tested
against.
| Case | Message |
|---|---|
| an unknown node type is refused, lowercased in the message | Unknown node type "marquee" |
| text without a text field is refused | Missing required field "text" |
| a five-digit hex value is refused | Invalid value for "color": #12345 |
| non-hex characters are refused | Invalid value for "color": #GGGGGG |
| a colour without the hash is refused | Invalid value for "color": 3DDC84 |
| font style values are case-sensitive | generic decoding error |
| timer without a date is refused | Missing required field "until" |
| timer style names the timer style, not a font style | generic decoding error |
| a date with no timezone is refused | generic decoding error |
| month 13 is refused | generic decoding error |
| day 32 is refused | generic decoding error |
| gauge without a value is refused | Missing required field "value" |
| an inverted gauge range is refused | Invalid value for "max": 2.0 |
| a zero-width gauge range is refused | Invalid value for "max": 5.0 |
| a non-http image scheme is refused | Invalid value for "url": file:///etc/passwd |
| a schemeless image url is refused | Invalid value for "url": example.com/a.png |
| a payload with neither view nor entries is refused | Response contained no "view" or "entries" |
| a payload without v is refused | Missing required field "v" |
| a future protocol version is refused rather than misread | Payload version 2 is newer than this app supports |
| a view nested one level too deep is refused | View tree nested deeper than 12 levels |
| a view one node over the limit is refused | View tree exceeds 300 nodes |
| the node budget spans every entry, not each one separately | View tree exceeds 300 nodes |
| padding that is neither a number nor an object is refused | generic decoding error |
| a padding edge that is not a number is refused | generic decoding error |
Where the table says generic decoding error, the outcome is still a refusal — the two runtimes simply word it differently, so the corpus asserts that the payload fails without asserting how it is described.
Transport failures
| Condition | Message |
|---|---|
| Not HTTPS | you.example is not HTTPS |
| Credentials over plain HTTP | Refusing to send credentials over plain HTTP to you.example |
| Non-2xx status | Server returned HTTP 503 |
| Body too large | Response is 400000 bytes, over the 262144 byte limit |
| Wrong content type | Cannot render Content-Type "text/html" |
| Image too large | Image is 4000px on its longest side, over the 2000px limit |
| Undecodable image | Response was not a decodable image |
| Redirect loop | Exceeded 3 redirects |
Counting nodes
The 300-node budget is counted across the whole response, not per entry. A timeline of 12 entries at 30 nodes each is already at 360 and will be refused. If you are generating rows in a loop, cap the loop.
Depth is counted the same way, and 12 levels is more than any sensible layout needs — if you are close to it, something is wrapping that does not need to.