Weak-Network Swaps on 3G: What Actually Breaks (2026)
On Slow 3G the swap page looks ready at 1.6 s but isn't usable until 6.8 s: early input is erased, slow typing costs four quotes, stale responses flip the price.
At 14:03 UTC on 6 November 2026 I opened veilswap.io/exchange in a fresh mobile Chromium profile throttled to Chrome's Slow 3G preset (400 ms round-trip, 400 kbps each way) and recorded every request. The input was on screen at 1.6 s, but the first data request, /api/currencies, did not leave until 6.84 s - React only took over then. In between, 40 requests delivered 235 KB; the three largest JavaScript chunks (54.7, 47.0 and 28.2 KB) took 6.09 s, 5.91 s and 5.04 s. The quote response is 181 bytes and the API takes 0.44-1.10 s, so pricing was never the bottleneck. Unthrottled, the first request leaves at 0.589 s. On a weak link the page is slow because it is heavy.
Failure one: the form is visible before it is alive. I typed 100 at 1.93 s, while server-rendered HTML was on screen and JavaScript was still downloading. At 15 s the field was empty and zero quote requests had been made. Hydration had replaced the DOM value with React's initial state - an empty string - so the amount vanished without an error. That is not a React bug; it is what hydration does when a form ships in HTML and its state in JavaScript. On a fast link the first request leaves at 0.589 s; on Slow 3G at 6.84 s. Wait for the currency count or a rate before typing, and retype if your value vanished.
Failure two: the 500 ms debounce only forgives fast typing. The code waits 500 ms after the last keystroke before sending /api/quote. Filling 100 in one go produced one request and the receive amount 1.49 s later. Typing 1000 at 900 ms per digit produced four requests - amount=1 at +0.51 s, 10 at +1.42 s, 100 at +2.32 s, 1000 at +3.23 s - with two in flight at once; the first two returned 400 because 1 and 10 USDT are below the pair's 19.219061 USDT minimum. Each round trip took 0.7-1.1 s. The endpoint allows 60 requests per IP per 10 minutes, counted before validation, so about 15 slow edits can exhaust it. The 8-second cache keys on from|to|amount: repeating 100 USDT-TRX within five seconds returned the same 181-byte body in 240-295 ms, but a one-cent change misses it. (XMR to USDT exit)
Failure three: responses can land out of order, and the older one wins. The quote effect has no AbortController and no sequence number; every response calls setQuote. With two real quotes at 14:07 UTC (100 USDT -> 0.14082928 XMR; 1,000 -> 1.592011), I delayed the first response by 2.5 s and the second by 0.1 s. Typing 100 then 1,000 left 0.140829 on screen while the input read 1,000 - an 11.3x understatement. Reversing the delays left 1.592 in the receive line while the input read 100; the rate line showed 0.00159183 against the correct 0.00140888, 13.0% high. The order endpoint re-quotes and uses the requested amount, so execution is not at the stale price - but the screen is wrong. Wait for the rate to settle, or reload before ordering. (XMR to USDT exit)
Failure four: the order page polls every five seconds with no timeout and no in-flight guard. The code calls /api/order/<id> immediately, then setInterval(5000); each call is independent, with no AbortController and no 'skip this tick if the last request is still open'. I served a fake waiting_deposit response after 0.5 s and held every later response open. Requests arrived at 1.6, 7.1, 12.1, 17.1, 22.1, 27.1 and 32.1 s - exactly 5.000 s apart - and six were in flight at the end. The page kept showing 'Waiting for deposit'; the amber 'status sync delayed' banner never appeared, because it is set only when the upstream returns pollError, not when fetch hangs or returns non-2xx. A hidden mobile tab may throttle the timer further. The order itself does not depend on this page: the deposit address is the pool's and the payout runs on the pool's clock.
Failure five: the retry economy is harsher than it looks. The Exchange button is disabled while creating is true, but the click handler has no second guard. The server is the real wall: identical fromAsset|toAsset|fromAmount|toAddress from the same IP within 90 seconds returns 429 duplicate order before any provider call. With a deliberately below-minimum 1 USDT order at 14:06 UTC, the first POST took 1.128 s and returned 400 amount below minimum (minAmount 19.219061, maxAmount 165,135.014302); the identical second POST returned 429 duplicate order in 0.234 s. Cheap and correct. What is not cheap: the six-orders-per-hour-per-IP limiter runs before field and address validation, so every failed attempt burns a slot. Six typo'd refund addresses can lock a real order out for the rest of the hour. Fix the address in the form; if you see 'duplicate order', open the order link instead of nudging the amount. (recovering misdirected funds)
What to do on 3G, and what the page should change. As a user: wait for the currency count or a rate before typing; paste the amount if the link is slow; after each edit, wait for the receive line to stop moving and check that the '1 USDT approximately' rate matches; treat a spinner longer than 10 seconds as 'not yet', not 'failed'; keep the order link, but don't leave the order tab open for an hour. For the page: adapt the debounce to RTT, abort the previous quote and ignore out-of-order responses, fetch the 71 KB currency catalog once instead of twice (13.9 KB compressed each), shrink the first bundle that gates hydration, add poll timeout/backoff and a refresh on visibilitychange, and disable the form until hydration completes. (XMR to USDT exit)
Closing: the swap survives; the screen is the weak point. A no-KYC swap is mostly an address and a deposit: the pool holds the funds and pays out on its own clock, so a bad link cannot cancel an order already created. It can show the wrong number, swallow input, or make you tap twice. As of writing (6 November 2026, 14:0x UTC), 100 USDT-TRX quoted 0.14082928 XMR on one route with validUntil 120 seconds out; 1,000 quoted 1.592011 XMR across two routes; the pair's floor was 19.219061 USDT and the ceiling 165,135.014302 USDT; the catalog listed 436 assets across 65 networks. Those numbers move; the page's live quote governs. (XMR to USDT exit)