3 min read
The 429 Storm and 300 Idle Swipers
Performance forensics in a live auction viewer — one hidden request per bid per viewer, and a catalog tab that booted 300 carousels nobody asked for
Angular Performance Real-Time WebSockets Debugging
Live auctions are a hostile environment for frontend performance: every price tick fans out to every connected viewer, so any per-update cost is multiplied by bids per minute times the audience. Two investigations from this month show how those multiplications sneak up on you.
One innocent line, one request per bid per viewer
During fast bidding, viewers started seeing HTTP 429s — rate-limit rejections — on a pedigree-image endpoint. The endpoint has a modest per-IP throttle of 30 requests per minute, which should be unreachable for a screen that shows one lot at a time.
The chain: our auction coordinator rebroadcasts the full lot-details message on every price tick, not just when the lot changes — a reasonable protocol choice, since it keeps late joiners current. The viewer's handler for that message called "check whether this lot has a pedigree image" unconditionally. One line, obviously harmless in isolation. Multiply: every bid, on every viewer's device, fired a pedigree check. A lively lot with fast bidding burned through 30 requests per minute per viewer within seconds — and each successful check bumped a cache-busting parameter that forced the pedigree image itself to re-download from the CDN. Per tick.
The fix is almost embarrassingly small: remember the last barn-and-lot pair you checked, and skip the call until the lot actually changes. The diagnostic lesson is bigger: in broadcast-driven UIs, audit every subscription handler for work that doesn't depend on what changed. The rebroadcast pattern means your handler's cost model is "per message," and the message rate is set by the auctioneer's cadence, not your UI.
The Lots tab that couldn't open
Same viewer, different symptom: on sales with large catalogs — around 300 lots — opening the Lots tab visibly hung the page. Profiling found three compounding costs:
Destruction by tab switch. The tab panes rendered under @if, so every switch destroyed and rebuilt ~300 heavy rows from scratch. Fix: keep the tab mounted and hide it with CSS — the classic memory-for-latency trade, and the right one when the content is this expensive to build.
Change detection swept all rows on every tick. The component mirrored each incoming lot-details broadcast into a fresh object regardless of whether the lot had changed, sweeping change detection across all 300 rows per price tick — and each row re-ran a linear lookup against vaccination data, making the per-tick cost O(rows × data). Fixes: gate the mirror on lot identity so unchanged broadcasts don't produce new references, and precompute a per-lot map with stable identities so row lookups are O(1) and rows without changes don't re-render.
300 idle carousels. Each row instantiated a real Swiper carousel for vaccine badges — even though most lots have none. Three hundred instantiated, initialized, event-bound carousels, almost all displaying nothing, turned out to be the single biggest creation cost. Fix: create the carousel only when there are badges to show, and render rows in chunks — a viewport's worth immediately, the rest in idle-time batches.
And a bonus discovery that had nothing to do with performance: the lots endpoint had no ORDER BY at all. The catalog had been displaying in whatever order the database engine felt like, for years, and nothing downstream had ever sorted it. Nobody had noticed because the engine's incidental order was usually close to sale order. Every performance investigation seems to shake loose one correctness bug like this — profiling forces you to actually read the data path.
The pattern behind both
Neither bug was written by someone doing something wrong. A pedigree check on lot details is reasonable; a carousel component per row is idiomatic. They became problems only under multiplication — per tick, per row, per viewer — and multiplication is invisible in code review because each factor lives in a different file. The defense is knowing your multipliers: in a realtime UI, every handler cost is (message rate × subscriber count), and every row cost is (row count × update rate). Write those products down before shipping, and profile the creation path, not just steady state — our worst offender wasn't slow updates, it was 300 carousels booting for nobody.