Reducing payload size
Most of a CrateStack response is decided by the.cstack schema: which fields exist, what
type each one has, and what a client is allowed to ask for. This guide ranks the levers by
what they measurably save, so you spend schema churn where it pays.
The short version:
- Compress responses. On a list endpoint that alone removes 65–75 % of the bytes.
- Let clients speak CBOR. It is already the default, except for the TypeScript REST client.
- Ask for less:
?fields=,includeFields[path],@@paged,@server_only. - Then pick compact types. Model closed sets as
enum, notString. Explicit enum ordinals (since 0.17.0) store and send an enum as a small integer. They help most where nothing compresses the bytes.
minicbor-serde 0.7.1, configured as CborCodec configures it) and serde_json. The method
and raw output are in the framework design doc
docs/design/enum-ordinals.md §8.
What the levers are worth
A list response of 500 rows, each{ id: Uuid, eventType, status, createdAt: DateTime, amount: Decimal },
with varied data:
Read that table in both directions:
- Compression dwarfs type choices. Gzip takes the string-y CBOR body from 59 KB to 19 KB. Switching both enums to ordinals then saves another 1.3 %. Repeated field names and repeated enum strings are exactly what a compressor eats.
- Without compression, types matter. Ordinals cut the raw CBOR body by 20 %. An epoch
timestamp then removes another 10 KB, a further 17 % of the original. Some bytes are never compressed:
application/cbor-seqstreams, sealed signed-transport payloads, outbox rows, a mobile client on a proxy that stripsContent-Encoding. Type choices are what you have there.
1. Compress responses
CrateStack does not compress for you. The generated router is a plainaxum::Router, so
tower-http’s compression layer goes on it like any other layer:
dart:io’s HttpClient and reqwest (with its gzip feature) decompress
transparently. The framework does not test this layer together with the
signed transport envelope layer. If you use both, check one sealed
round trip yourself.
2. Let clients speak CBOR
Generated routes answerapplication/cbor by default and application/json when the client
asks for it in Accept (HTTP transport contract).
CBOR is smaller than JSON for the same value:
Which clients use it:
- Rust client: CBOR.
- Dart client: CBOR by default (
application/cbor). See Dart HTTP transport. - TypeScript,
transport rpc: native CBOR through@cratestack/cborby default.--no-native-cborfalls back to JSON. - TypeScript, REST: JSON only. The REST runtime has no codec seam (TypeScript client generation). Compression is the lever for a REST browser client.
3. Ask for less
These remove bytes outright rather than encoding them more tightly. They work the same over REST and RPC.?fields=limits a read to the named fields.includeFields[path]does the same for an included relation. Generated clients expose both through their selection builders (client runtime). A@computedfield left out of?fields=is not even resolved (computed fields).@@pagedwith alimitgives an unbounded list a page size (pagination).@server_onlykeeps a field off the wire entirely, for columns no client should see.
4. Choose compact types in the schema
Where each type is a string, and where it is not
So in Postgres a
DateTime is not stored as a string. It is an 8-byte TIMESTAMPTZ, and
it becomes a string only on the wire, on both codecs. The embedded SQLite backend is different:
every column is declared BLOB, and a DateTime is stored as RFC 3339 text
(offline-first SQLite), so there a timestamp really is a string at
rest.
Closed sets: use an enum, not a String
A status String and a status Status cost the same bytes: both go out as text, until the enum
is made ordinal. Use the enum anyway, because the gains are correctness:
- The column gets a membership
CHECK, so the database refuses a typo’d status. - A filter like
?status=Pendngis refused, and the error names the accepted values. - Generated clients get a real type: a TypeScript string union, a Dart
enum, a Rustenum. - Policy literals (
auth().role == admin) are checked against the variants at compile time.
String for sets that churn outside your release cycle. Currencies are String @iso4217
for exactly that reason (choosing types for money).
Adding an enum variant is a schema change and a client regeneration.
Don’t shorten variant names into codes (PR for PendingReview) to save bytes. Under
compression the saving is near zero, and the cost is a schema nobody can read.
Identifiers
Int ids are 1–9 bytes. Uuid is 17 bytes on CBOR and 38 on JSON. A Cuid is text. Choose by
generation and exposure needs first: a Uuid can be minted offline and does not leak row
counts. Size is a tiebreaker.
Timestamps
KeepDateTime. An Int epoch is smaller on the wire (5–9 B against 21–32 B), but you give up
@default(now()) (it requires a DateTime field), TIMESTAMPTZ semantics and operators in SQL,
and RFC 3339 parsing in filters such as ?createdAt__gte=. Consider an epoch only on a
high-volume, append-only table that is read by machines and never filtered by date in a URL.
Money
KeepDecimal. Integer minor units are a few bytes smaller, but they move the scale into
convention instead of the type.
Field names
Every row repeats every key, socreatedAt costs 10 bytes per row before its value. Pick clear
names, not short ones; ?fields= and compression handle the rest. No attribute renames a field
on the wire only. @rename(from = "…") is a migration marker.
Enum ordinals
Since 0.17.0. On 0.16.0 and earlier,
Pending = 1 is a parse error. The design and its measurements are in
docs/design/enum-ordinals.md.
Application code keeps using names:
- Rust: the same enum, now
#[repr(i16)], withordinal()andTryFrom<i16>;DisplayandFromStrstill use names. - TypeScript: the type stays
'Pending' | 'Approved' | …. Responses are revived from numbers to names. Requests still send names, so a TypeScript client does not save bytes on what it sends. - Dart: the same constants, with
int wireValuebesidewireName. - Studio shows
Approved (2). WireMock stubs carry numbers. An MCP tool’s input schema lists each number with its variant’s name as the title.
Why the mapping must be explicit and enforced
The tempting shortcut is to use each variant’s position in the declaration. That breaks silently the first time someone sorts the variants or inserts one in the middle: every stored row now means a different status, and nothing errors. So the mapping is explicit and append-only:- Every variant has a number, or none does. Numbers are unique, from 0 to 32767.
cratestack migrate diffrefuses to change a variant’s number, to remove a variant without listing its number in@@reserved, to drop a number from@@reserved, and to reuse a reserved number.- Renaming a variant while keeping its number is allowed and moves no data.
- Removing a variant, here or from a string enum, is a blocking migration: rows may still
hold the value, so
up.pre.sqlis scaffolded withUPDATE … SET status = <a value the enum keeps> WHERE status IN (5)to adapt (since 0.17.0). cratestack diffreports removed and added variants, for every enum (schema diff).- An
authfield may not have an ordinal enum type: a claim’s encoding is the auth provider’s choice, and a policy comparing it with a number could fail open.
”We don’t need readable values in the database any more”
The argument for ordinals is that the schema is the decoder: anyone with the.cstack file, an
AI assistant included, can read status = 3 as Rejected. That is true where the schema
travels with the data:
- Generated clients and Studio show names.
psql, BI tools, a backup restored years later, and audit or outbox payloads show integers.
Converting an existing enum
Add the numbers to the enum and runcratestack migrate diff. It writes the data migration:
- Postgres: the column changes type with
USING (CASE status WHEN 'Pending' THEN 1 … END), and its default andCHECKfollow. It is classified lossy (it rewrites the table under an exclusive lock), somigrate diffwrites it only with--allow-destructive. - SQLite:
UPDATE … SET status = CASE status WHEN 'Pending' THEN 1 … END.
enum
now: converting an enum is one schema edit, converting a String column is a redesign.
On the wire the conversion is a breaking change. Regenerate the clients and deploy them with the
server. Clients you cannot update in lockstep, such as installed mobile apps, are the hard case:
a procedure can serve the new shape beside the old one under
@api_version, but model routes are
not versioned, so an enum those apps read through a model can only become ordinal once they have
updated.
Limits
- Sorting by an ordinal enum (
?sort=status) orders by number. cratestack migratereading a live Postgres reports drift on an ordinal column, because it does not mapSMALLINTor parse the numericCHECK.
Measuring your own API
Compare sizes per codec and with compression, against a real endpoint:%{size_download} counts bytes as received, so the gzip line shows the compressed size.