Reference

Design Decisions

Every non-obvious choice made during RoExpress development | what the alternatives were, and why this path was taken.

One RemoteEvent by default
Route all requests through a single shared remote

Roblox enforces a cap on RemoteEvents per place. A dedicated remote per route would hit the cap quickly in a large game and make tooling (replication, profiling) harder to reason about.

The single-remote model adds a small per-request envelope | method and route string | but the overhead is negligible versus the structural win of centralised rate limiting, middleware, and logging. Ports exist for the cases that genuinely need isolation without giving up the rest of the framework.

Alternatives considered: one remote per module, one per route. Both hit the cap too easily.

HTTP verbs over string event names
GET/POST/PUT/DELETE carry semantic intent by convention

Arbitrary string names for remote events encode no information about whether the call is read-only or mutating, expected to return data or just trigger a side effect. You can't tell from the name alone whether a "stats" event reads or writes stats.

HTTP verbs are a well-established convention: GET is read-only and idempotent, POST creates or triggers, PUT replaces, DELETE removes. Any developer familiar with web APIs reads a handler's intent immediately. The same semantic constraint also makes it easier to apply middleware selectively | e.g. rate-limit POST more aggressively than GET.

Token bucket over fixed-window rate limiting
Allows legitimate bursts without penalising normal traffic

Fixed-window rate limiting resets a counter on a clock boundary. A player who sends 9 requests at 11:59:59 and 9 at 12:00:01 fires 18 requests in 2 seconds without triggering a 10-per-minute limit.

The token bucket (RFC 2697) solves this. Tokens accumulate at a fixed rate up to a burst capacity. Each request costs one token. Bursts draw down the bucket but legitimate continuous traffic always gets tokens as fast as they refill. Menu opens legitimately burst a few requests; the bucket handles this gracefully. An exploit that fires continuously drains the bucket and gets 429s.

Based on: RFC 2697

Compression is opt-in per route
Not all payloads benefit | compression adds CPU cost

Compressing small payloads (a single number, a boolean) adds overhead without reducing size. LZ77 only wins when there's repetition in the data | JSON with repeated keys, arrays of similar objects, large text.

Making compression opt-in per route via { compress = true } lets developers apply it exactly where it helps: bulk data responses, leaderboard pages, catalogues. High-frequency low-latency routes like combat/hit stay uncompressed.

Based on: Ziv & Lempel 1977

Reliable push on the same RemoteEvent
Piggyback server push on the existing reliable channel

Adding a second RemoteEvent for server push would double the remote count and split rate limiting across two channels. The alternative was to multiplex push packets on the existing response channel.

Push packets carry type = "push" in their envelope. The Listener filters on this field | request-response packets are routed to Network callbacks, push packets to Listener subscriptions. From a developer's perspective the separation is invisible: push uses listener:On, responses use network:Get callbacks.

Bridge as internal event bus | no shared imports
Modules communicate without requiring each other as dependencies

In a large server codebase, modules that import each other create dependency cycles and tight coupling. A SpawnService that imports RoundManager imports ScoreService imports SpawnService | circular, untestable.

Bridge provides a named-event bus. RoundManager fires "round.began"; SpawnService listens for it. Neither module imports the other. The coupling is in the event name, not the module reference | much easier to change or remove one side independently.

Stream channels over a position API
State sync shouldn't be hardcoded to FPS player positions

Stream v1 and v2 were purpose-built for broadcasting player positions every tick. The schema was hardcoded: x, y, z, rotY, health. This worked for FPS games but nothing else.

Stream (shipped in v2.4) takes a schema at construction time | you declare field names and types, Stream handles packing and unpacking. The FPS use case still works, but now you can stream wheel rotation, economy ticks, weather, or any other high-frequency state. The batch-broadcast model (one buffer, one FireAllClients per tick) is retained regardless of schema.

Compact args detect signature at dispatch
Both handler forms coexist | no migration required

When compact args (v2.3) were added, there were already many games using the four-argument form function(Player, Payload, req, res). A breaking change would have forced every user to update.

The dispatch path uses debug.info to count the handler's declared parameters at registration time. Four parameters → old form, route normally. Two parameters → compact form, wrap Player and Payload into req.player and req.raw. The detection runs once at registration, not on every call.

Lag compensation via history rewind
Server validates against where the target was when the client fired

Without lag compensation, a client with 150 ms ping sees the world 150 ms behind the server. If they shoot where a player is on their screen, the server has moved that player 150 ms forward and rejects the hit as a miss. High-ping players are systematically disadvantaged.

Stream keeps a rolling position history per channel entry. When a hit claim arrives with a client-side timestamp, ch:Rewind(userId, ts) returns the position that player was at when the client fired. Validation uses the rewound position, making hit registration fair regardless of ping.

Based on: Bernier 2001

TypeCoercer as a separate utility
Type serialization shouldn't be baked into the request path

RemoteEvent payloads and DataStore both accept only plain Lua types. Roblox types (Vector3, CFrame, Color3) need serialization. The question was whether to handle this automatically in the App/Network layer or expose it as a separate utility.

Baking it in would mean invisible overhead on every payload scan. Making it explicit (developer calls TC.ToString and TC.FromString where needed) means the cost is only paid when the developer chooses it. Most routes pass plain tables | they pay nothing.

Middleware returns false for rejection
Explicit false distinguishes "reject" from "pass through silently"

Early prototypes used a truthy/falsy convention | return any falsy value to reject. The problem: forgetting a return in a logging middleware that should pass through would silently reject every request.

Requiring a literal false return makes the convention explicit. A middleware with no return value (or a missing return) always passes through. Only a deliberate return false rejects. This makes middleware safe to write by default.

Ports for isolation, not as the default
Namespace isolation at the cost of an extra remote | opt-in only

Some teams want completely separate rate limiting, middleware, and event channels for different game systems. A Port gives them that by creating a fresh App instance on its own RemoteEvent. But the cost is a second remote.

Making Ports opt-in keeps most projects on a single remote. Games that need isolation (a competitive combat system that must never share rate limiting with a chat system) can use a Port for just that system, while everything else shares the main App.

See also

The Story | how RoExpress evolved  ·  Research Papers | academic foundations  ·  Library Comparison | where these choices lead vs other libraries