Not all at once. It starts with one RemoteEvent. Then five. Then fifteen. Then someone finds the one you forgot to validate, and suddenly your economy is broken and your leaderboard is full of accounts with 10 billion coins.
It starts with one remote. PlayerDataRequest. Fine. Then you need shop purchases | ShopBuyRequest. Then combat hits. Then admin commands. Then leaderboard sync. Three months later you have thirty-seven RemoteEvents, half of them are named things like RemoteEvent14, and you genuinely cannot remember what six of them do.
There is no index. There is no routing. Changing a route means hunting through every script that references that string. Adding logging to all requests means touching every handler individually.
-- ReplicatedStorage -- ├── PlayerDataRequest -- ├── ShopBuyRequest -- ├── ShopSellRequest -- ├── CombatHitRemote -- ├── AdminKickEvent -- ├── LeaderboardSync -- ├── RemoteEvent14 -- └── ... 29 more -- Every script wires up its own remote local buyRemote = RS:WaitForChild("ShopBuyRequest") local sellRemote = RS:WaitForChild("ShopSellRequest") buyRemote.OnServerEvent:Connect(function(player, data) -- what shape is data? who knows end)
-- One remote. Every route in one place. local app = RoExpress.GetApp() app:Post("shop/buy/:itemId=string", handler) app:Post("shop/sell/:itemId=string", handler) app:Post("combat/hit", handler) app:Post("admin/kick/:userId=number", handler) -- One global middleware log for everything app:Use("logger", function(player, payload) print(player.Name, payload.method, payload.route) end)
app:Use() call that covers everything | not editing thirty files.
RemoteEvents fire from any client, with any data, at any rate, with no validation. A single exploiter with a script executor can call ShopBuyRequest ten thousand times a second with crafted arguments your handler never expected. If you forgot to validate one field | and you will forget, because there's no framework reminding you | the economy breaks.
Rate limiting is not optional. It is not something to add later. And writing it from scratch for every remote is how bugs slip through.
-- You have to write this for every single remote local lastFired = {} remote.OnServerEvent:Connect(function(player, data) local now = tick() if lastFired[player] and now - lastFired[player] < 0.5 then return -- silently drop, no kick, no logging end lastFired[player] = now -- now validate data... manually... every time if typeof(data) ~= "table" then return end if typeof(data.itemId) ~= "string" then return end -- ... end)
-- Rate limiting: built in. Runs before every handler. -- Tamper: passive exploit detection, auto-strike. -- Types: enforced in the route string itself. app:Post("shop/buy/:itemId=string", function(req, res) -- req.params.itemId is already a string -- player already rate-checked (429 if over limit) -- payload already validated (400 if malformed) -- version already verified (silent drop if spoofed) ShopData.Buy(req.player, req.params.itemId) res:Send() end)
Admin routes. Moderator-only actions. Whitelist checks. Every game has them. And without a middleware system, the only way to protect them is to paste the same guard at the top of every handler. Then someone adds a new admin route and forgets the check. Then a player finds it.
There is no clean way to share logic across routes with vanilla RemoteEvents. Middleware is the solution and Roblox gives you nothing.
-- Pasted into every admin handler. Every single one. kickRemote.OnServerEvent:Connect(function(player, target) if not AdminList[player.UserId] then return end -- actual logic end) banRemote.OnServerEvent:Connect(function(player, target) if not AdminList[player.UserId] then return end -- actual logic end) announceRemote.OnServerEvent:Connect(function(player, msg) if not AdminList[player.UserId] then return end -- someone will forget this one day end)
-- One middleware. Covers every admin route. app:Use("admin", function(player, payload) if payload.route:sub(1, 6) ~= "admin/" then return end if not AdminList[player.UserId] then return false end end) -- Handlers contain zero auth boilerplate app:Post("admin/kick/:userId=number", kickHandler) app:Post("admin/ban/:userId=number", banHandler) app:Post("admin/announce", announceHandler)
false to reject with 403. New routes added later are protected automatically | no extra work.
Fire-and-forget is not a request/response pattern. You fire a RemoteEvent, the server does something, and if it fails | network hiccup, server overload, script error | the client has no idea. No timeout. No retry. No callback. The player just sits there wondering why nothing happened.
Worse: without response semantics, you cannot tell the difference between "server received this and said no" and "server never received it at all."
-- Client fires and hopes for the best buyRemote:FireServer({ itemId = "sword" }) -- Need a result? Wire a second remote back local resultRemote = RS:WaitForChild("ShopBuyResult") resultRemote.OnClientEvent:Connect(function(success, msg) -- hope the server fires this before timeout -- what timeout? you don't have one end)
-- Callback style: one round-trip, one response network:Post("shop/buy/sword", nil, function(res) if res.ok then UI:ShowSuccess("Purchased!") else UI:ShowError(res.data.message) end end) -- Promise style: chainable, with auto-retry network:PostAsync("shop/buy/sword") :Then(function(res) UI:ShowSuccess() end) :Catch(function(err) UI:ShowError(err.message) end)
Player positions. Health bars. Economy values. Anything that changes frequently gets sent as a RemoteEvent payload | usually a table, usually serialised to a format Roblox's replication layer was not designed for. At 20 players and 20 ticks per second, that is four hundred FireAllClients calls per second carrying JSON-shaped tables.
The packets are large. The bandwidth adds up. And you are not getting delta compression or lag compensation for free.
-- One FireAllClients per player per tick -- Table overhead, no compression, no delta RunService.Heartbeat:Connect(function() for _, player in Players.GetPlayers() do stateRemote:FireAllClients({ userId = player.UserId, position = player.Character.HumanoidRootPart.Position, health = player.Character.Humanoid.Health, -- full table every tick, no delta }) end end)
-- One packed buffer per tick, all players -- Delta compression built in local ch = Stream.Channel({ userId = "uint32", position = "vector3", health = "float32", }) RunService.Heartbeat:Connect(function() for _, player in Players.GetPlayers() do ch:Write(player.UserId, { position = player.Character.HumanoidRootPart.Position, health = player.Character.Humanoid.Health, }) end ch:Flush() -- one FireAllClients, only changed records end)
Discord webhook for round results. External leaderboard API. Analytics endpoint. Score submission. Every game eventually needs to talk to something outside of Roblox. And every time, you write the same boilerplate | GetService, GetAsync, JSONEncode, pcall, retry logic, header management | from scratch, in a different script, with slightly different error handling each time.
-- Written from scratch, every time, in every game local HS = game:GetService("HttpService") local function sendWebhook(winner) local ok, err = pcall(function() HS:RequestAsync({ Url = WEBHOOK_URL, Method = "POST", Headers = { ["Content-Type"] = "application/json" }, Body = HS:JSONEncode({ content = winner .. " won!" }), }) end) if not ok then -- retry? manually? give up? end end
-- Configure once. Use everywhere. local webhook = RoExpress.Harpy.New({ base = WEBHOOK_URL, retries = 3, }) -- Body JSON-encoded automatically -- Retries on failure automatically -- Headers set once, used on every call webhook:Post("", { content = winner .. " won!" }) -- Multiple APIs? One client each. local scores = RoExpress.Harpy.New({ base = SCORES_API }) scores:SetHeader("X-Api-Key", API_KEY)
RoExpress solves every one of them. One framework, every layer, MIT licensed and free forever.