Most rate limits are a tax on your software. You send too much, you get a 429, you back off, your feature gets slower. Annoying, self-correcting, and entirely your problem.
Steam’s inventory and market endpoints are not like that. The limit is enforced on the IP address, not on the application. Exceed it and Steam does not slow your tool down — it stops answering that address for hours. Which means the punishment does not land on the developer, or on the software, or even on the feature. It lands on the person who installed it, in a place they were not thinking about, doing something unrelated.
Who pays
Concretely: a user opens an inventory valuation tool, the tool fetches prices as fast as it can, and twenty minutes later that person cannot load their own Steam inventory page. Not the tool’s inventory page — Steam’s. The market does not load either. It lasts hours, and tripping the limit again during the cooldown re-arms it. On a shared connection it takes the whole household down, including people who never installed anything.
This is the documented cause of death for a generation of Steam market browser add-ons. They were not malicious and mostly were not even careless by the standards of the time — they simply made a request per item, on page load, without asking, back when that was affordable. Then the ceiling came down and the behaviour that had been normal became the behaviour that got your users banned.
The useful generalisation: when a quota is enforced on a shared resource the user owns, throttling stops being politeness and becomes a correctness requirement. A tool that is merely fast is not neutral. It is spending something that is not its own.
What that forces
Once the constraint is stated that way, most of the design writes itself, and several decisions that would otherwise look like product cowardice become obviously correct.
- Nothing fetches on its own. No background jobs, nothing on open, no polling. A network call happens because a person pressed a button and can see it happening. This costs a “live” feeling and buys the guarantee that the tool cannot burn quota while forgotten in a tab.
- Pace below the ceiling, not at it. Sitting at roughly 60% of the documented limit means a miscount, a retry, or a second tab does not push past it. The margin is the point.
- Count failures against yourself. Failed calls consume quota at the service too, so a client that only counts successes is quietly over budget exactly when things are going wrong.
- Persist the budget. If the counter lives in memory, closing and reopening resets it and the user can spend the day’s allowance ten times over without ever seeing a warning.
- Prefer one bulk call to a hundred small ones. Bulk endpoints are usually there. Using them is often a ten-fold reduction in requests for the same answer.
- Stop hard on the first refusal, and say why. Not exponential backoff — a real halt, for hours, persisted. And the message has to explain that the limit is on their address, because otherwise the user reasonably assumes the tool is broken and reinstalls it, which makes everything worse.
Spend compute, not requests
The second-order effect is the interesting one. When requests are the scarce resource, the roadmap reorganises around what can be built from data you already hold.
Sorting, filtering, grouping and search cost nothing. A value history assembled from the user’s own past refreshes costs nothing, and is more honest than a market average because it is actually their holdings. Flagging what has appeared since last time costs nothing. And the feature that ended up being the most distinctive — comparing what someone owns against what they actually play — costs nothing either, because both datasets were already cached for other reasons.
A tight quota is a design forcing function. It pushed the work toward the one thing a competitor hitting the same endpoint could not trivially copy, because the differentiator was never the price lookup. Everyone has the price lookup.
The part I got wrong
Worth recording, because it is the same mistake in a different costume. The first build could not read an inventory at all: I set the page size to 5000 because that is the number third-party libraries publish. Steam returns HTTP 400 at 5000. The largest value that works is 2000.
I had flagged the response shape as unverified and treated the request as settled — exactly backwards, since the request was the part I had never tested and the part a single live probe would have answered in seconds. Convention is not evidence. Neither is a widely-copied constant.
The shipped implementation of all of this is Dota Companion; the release writeup is here, and the short-form version of these questions is on the answers page.
