Simulate Mobile Network Conditions with Proxies for App Performance Testing
September 10, 2026
Why Simulate Mobile Network Conditions?
Mobile users experience wildly different network realities: spotty 3G on a commuter train, bursty 4G in a city centre, or ultra‑low‑latency 5G in a stadium. If you only test against a stable office Wi‑Fi connection, you miss latency spikes, packet loss, and bandwidth throttling that cause real‑world crashes, slow renders, and failed API calls. Simulating those conditions early lets you:
- Detect timeout‑related bugs before they reach production.
- Optimize asset sizes and caching strategies for low‑bandwidth links.
- Validate retry/back‑off logic under realistic jitter.
- Provide credible performance numbers to stakeholders.
Proxies are the perfect control plane because they sit between your test client and the target, allowing you to inject latency, limit bandwidth, and drop packets without touching the application code.
Proxy Types That Enable Network Emulation
| Proxy type | Typical use case | Built‑in shaping? | When to choose |
|---|---|---|---|
| Mobile (4G/5G) proxies | Real carrier IPs, genuine radio characteristics | Often yes (provider dashboard) | You need authentic ASN/geo and carrier‑level latency |
| Residential proxies | Broad ISP diversity, good for geo‑targeting | Rarely, but you can shape locally | Cost‑effective when you only need IP diversity |
| Datacenter proxies + local shaping | High throughput, low cost | No – you add shaping yourself | Internal load‑test labs where you control the host |
Pick the combination that matches your budget and realism requirements. For most mobile‑app teams, a small pool of mobile proxies plus local tc/netem shaping gives the best fidelity.
Setting Up a Proxy With Traffic Shaping
You have three practical ways to impose network profiles.
Option 1: Provider‑Side Throttling (RoProxy Mobile Proxies)
RoProxy’s mobile‑proxy API lets you attach a network profile to each session. Example curl to start a 4G‑typical session:
curl -X POST https://api.roproxy.com/v1/sessions \
-H "Authorization: Bearer $ROPROXY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"proxy_type": "mobile",
"geo": "US",
"network_profile": "4g_typical",
"sticky": true
}'
The response includes proxy_host, proxy_port, username, password. Use those credentials directly in your test scripts – no extra tooling required.
Option 2: Local Traffic Shaping with tc/netem (Linux)
If you run your own proxy (e.g., a tiny mitmproxy instance) on a Linux box, you can shape the outbound interface:
# Create a root qdisc on eth0
sudo tc qdisc add dev eth0 root handle 1: htb default 12
# 4G typical: 12 Mbps down, 5 Mbps up, 30 ms RTT, 0.5 % loss
sudo tc class add dev eth0 parent 1: classid 1:12 htb rate 12mbit ceil 12mbit
sudo tc qdisc add dev eth0 parent 1:12 handle 12: netem delay 30ms loss 0.5%
# Apply to traffic sourced from the proxy user (uid 1001)
sudo tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 match u32 0 0 flowid 1:12
Adjust rate, delay, loss to match 3G (1.5mbit, 100ms, 1%) or 5G (50mbit, 10ms, 0.1%). Remove with sudo tc qdisc del dev eth0 root when done.
Option 3: Programmable Shaping in Code
When you cannot touch the host (e.g., serverless CI runners), shape inside the client process.
Python – custom HTTPAdapter with urllib3
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class ShapedAdapter(HTTPAdapter):
"""Adds fixed latency and bandwidth throttling per request."""
def __init__(self, latency_ms=100, kbps=500, *args, **kwargs):
self.latency = latency_ms / 1000.0 # seconds
self.chunk_size = int(kbps * 1024 / 8) # bytes per second
super().__init__(*args, **kwargs)
def send(self, request, **kwargs):
# Simulate RTT latency
time.sleep(self.latency)
resp = super().send(request, **kwargs)
# Throttle response body streaming
if self.chunk_size and hasattr(resp.raw, 'stream'):
original_stream = resp.raw.stream
def throttled(amt=None):
for chunk in original_stream(amt):
yield chunk
time.sleep(len(chunk) / self.chunk_size)
resp.raw.stream = throttled
return resp
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
session.mount('http://', ShapedAdapter(latency_ms=150, kbps=800, max_retries=retries))
session.mount('https://', ShapedAdapter(latency_ms=150, kbps=800, max_retries=retries))
proxy = "http://user:pass@mobile-us.roproxy.com:8000"
resp = session.get("https://api.example.com/v1/feed", proxies={"http": proxy, "https": proxy}, timeout=10)
print(resp.status_code, resp.elapsed.total_seconds())
Node.js – http-proxy-agent + net socket delay
const http = require('http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { once } = require('events');
const proxy = 'http://user:pass@mobile-us.roproxy.com:8000';
const agent = new HttpProxyAgent(proxy);
function requestWithShaping(url, latencyMs = 120, kbps = 600) {
return new Promise((resolve, reject) => {
const req = http.request(url, { agent }, async (res) => {
// Simulate RTT
await new Promise(r => setTimeout(r, latencyMs));
const chunks = [];
const throttle = kbps * 1024 / 8; // bytes per sec
res.on('data', (chunk) => {
chunks.push(chunk);
// Simple per‑chunk throttle
const delay = chunk.length / throttle * 1000;
await new Promise(r => setTimeout(r, delay));
});
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.end();
});
}
(async () => {
const body = await requestWithShaping('https://api.example.com/v1/feed');
console.log('bytes:', body.length);
})();
Both snippets let you parameterise latency and bandwidth per test case, making it trivial to sweep a matrix of network profiles.
Integrating Into Automated Test Suites
Pytest + Requests (Python)
import pytest
import statistics
PROFILES = [
("3g", 1500, 500), # latency ms, kbps
("4g", 80, 5000),
("5g", 20, 20000),
]
@pytest.mark.parametrize("profile,latency,kbps", PROFILES)
def test_api_latency(profile, latency, kbps):
adapter = ShapedAdapter(latency_ms=latency, kbps=kbps)
s = requests.Session()
s.mount('https://', adapter)
proxy = "http://user:pass@mobile-us.roproxy.com:8000"
latencies = []
for _ in range(5):
r = s.get("https://api.example.com/v1/ping", proxies={"https": proxy}, timeout=5)
assert r.status_code == 200
latencies.append(r.elapsed.total_seconds() * 1000)
avg = statistics.mean(latencies)
# Assert that observed latency stays within 2× the injected latency
assert avg < latency * 2, f"{profile} avg {avg:.0f} ms > {latency*2} ms"
Playwright (Node.js) – Browser‑Level Network Conditions
Playwright can combine a proxy with its built‑in network emulation:
const { chromium } = require('playwright');
const profiles = {
'3g': { download: 1.5 * 1024 * 1024, upload: 768 * 1024, latency: 150 },
'4g': { download: 12 * 1024 * 1024, upload: 5 * 1024 * 1024, latency: 30 },
'5g': { download: 50 * 1024 * 1024, upload: 20 * 1024 * 1024, latency: 10 },
};
async function run(profileName) {
const p = profiles[profileName];
const browser = await chromium.launch({ proxy: { server: 'http://mobile-us.roproxy.com:8000', username: 'user', password: 'pass' } });
const context = await browser.newContext();
await context.setOffline(false);
await context.route('**/*', route => route.continue());
// Emulate network
const client = await context.newCDPSession(await context.newPage());
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: p.download,
uploadThroughput: p.upload,
latency: p.latency,
});
const page = await context.newPage();
const start = Date.now();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
console.log(`${profileName} load time: ${Date.now() - start} ms`);
await browser.close();
}
for (const name of Object.keys(profiles)) await run(name);
Measuring and Asserting Performance Metrics
Define a small KPI set per profile:
| KPI | Target (4G) | How to capture |
|---|---|---|
| TTFB (ms) | ≤ 250 | resp.elapsed (Python) or performance.timing.responseStart - requestStart (browser) |
| Full page load (ms) | ≤ 3000 | page.loadEventEnd - navigationStart (Playwright) |
| API error rate | < 0.5 % | Count non‑2xx over 100 requests |
| Retry count | ≤ 1 per request | Instrument retry logic |
Collect metrics in a CSV or push to Prometheus for trend analysis.
CI/CD Pipeline Integration
GitHub Actions (self‑hosted runner with tc)
name: Mobile‑Network‑Perf
on: [push, pull_request]
jobs:
perf:
runs-on: [self‑hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Install tc
run: sudo apt-get update && sudo apt-get install -y iproute2
- name: Apply 4G shaping
run: |
sudo tc qdisc add dev eth0 root handle 1: htb default 12
sudo tc class add dev eth0 parent 1: classid 1:12 htb rate 12mbit ceil 12mbit
sudo tc qdisc add dev eth0 parent 1:12 handle 12: netem delay 30ms loss 0.5%
- name: Run pytest suite
env:
ROPROXY_TOKEN: ${{ secrets.ROPROXY_TOKEN }}
run: pytest -q tests/perf/
- name: Cleanup shaping
if: always()
run: sudo tc qdisc del dev eth0 root
If you lack a self‑hosted runner, use the provider‑side profile (Option 1) – no host privileges needed.
Common Pitfalls & Best Practices
- Don’t shape the control plane – only shape traffic after the proxy handshake; otherwise authentication may time out.
- Reset shaping between test runs – leftover
tcrules corrupt subsequent profiles. - Use sticky sessions for login flows – rotate only after the authenticated session is established.
- Monitor proxy health – a dead mobile node will masquerade as “high latency”. Implement a quick health‑check (
GET /health) before each batch. - Log the exact profile parameters – store latency, bandwidth, loss in test artefacts for reproducibility.
- Avoid double‑shaping – if the provider already enforces a profile, skip local
tcto prevent compounding effects.
Full End‑to‑End Example (Python)
#!/usr/bin/env python3
"""Run a 3‑profile latency sweep against a target API using RoProxy mobile proxies."""
import os, time, statistics, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
PROFILES = {
"3g": (150, 500),
"4g": (80, 5000),
"5g": (20, 20000),
}
class ShapedAdapter(HTTPAdapter):
def __init__(self, latency_ms, kbps, *a, **kw):
self.latency = latency_ms / 1000.0
self.rate = kbps * 1024 / 8
super().__init__(*a, **kw)
def send(self, request, **kw):
time.sleep(self.latency)
resp = super().send(request, **kw)
if self.rate and hasattr(resp.raw, 'stream'):
orig = resp.raw.stream
def throttled(amt=None):
for chunk in orig(amt):
yield chunk
time.sleep(len(chunk) / self.rate)
resp.raw.stream = throttled
return resp
def make_session(latency, kbps):
s = requests.Session()
retry = Retry(total=2, backoff_factor=0.3, status_forcelist=[500,502,503,504])
adapter = ShapedAdapter(latency, kbps, max_retries=retry)
s.mount('https://', adapter)
return s
def fetch_proxy():
# Assume env var ROPROXY_SESSION contains a pre‑created session JSON
import json, base64
data = json.loads(base64.b64decode(os.getenv('ROPROXY_SESSION')).decode())
return f"http://{data['username']}:{data['password']}@{data['proxy_host']}:{data['proxy_port']}"
def main():
proxy = fetch_proxy()
target = "https://api.example.com/v1/feed"
for name, (lat, bw) in PROFILES.items():
sess = make_session(lat, bw)
times = []
for i in range(7):
r = sess.get(target, proxies={"https": proxy}, timeout=8)
r.raise_for_status()
times.append(r.elapsed.total_seconds() * 1000)
avg = statistics.mean(times)
p95 = sorted(times)[int(0.95 * len(times))]
print(f"{name:3s} avg={avg:6.1f} ms p95={p95:6.1f} ms samples={len(times)}")
if __name__ == "__main__":
main()
Full End‑to‑End Example (Node.js)
// run-perf.mjs
import { HttpProxyAgent } from 'http-proxy-agent';
import https from 'https';
import { performance } from 'perf_hooks';
const PROFILES = {
'3g': { latency: 150, kbps: 500 },
'4g': { latency: 80, kbps: 5000 },
'5g': { latency: 20, kbps: 20000 },
};
const SESSION = JSON.parse(Buffer.from(process.env.ROPROXY_SESSION, 'base64').toString());
const PROXY_URL = `http://${SESSION.username}:${SESSION.password}@${SESSION.proxy_host}:${SESSION.proxy_port}`;
const AGENT = new HttpProxyAgent(PROXY_URL);
const TARGET = 'https://api.example.com/v1/feed';
const SAMPLES = 7;
async function fetchWithShaping(latencyMs, kbps) {
const start = performance.now();
await new Promise(r => setTimeout(r, latencyMs)); // RTT simulation
return new Promise((resolve, reject) => {
const req = https.request(TARGET, { agent: AGENT }, (res) => {
let bytes = 0;
res.on('data', chunk => { bytes += chunk.length; });
res.on('end', () => resolve(performance.now() - start));
});
req.on('error', reject);
req.end();
});
}
async function run() {
for (const [name, { latency, kbps }] of Object.entries(PROFILES)) {
const durations = [];
for (let i = 0; i < SAMPLES; i++) {
durations.push(await fetchWithShaping(latency, kbps));
}
const avg = durations.reduce((a,b)=>a+b,0)/durations.length;
const p95 = durations.sort((a,b)=>a-b)[Math.floor(0.95*durations.length)];
console.log(`${name.padStart(3)} avg=${avg.toFixed(1)} ms p95=${p95.toFixed(1)} ms`);
}
}
run().catch(e => { console.error(e); process.exit(1); });
Summary Checklist
- Choose proxy type (mobile > residential > datacenter) based on realism vs. cost.
- Decide shaping layer: provider‑side, host‑side
tc, or in‑code adapter. - Define network profiles (3G/4G/5G) with latency, bandwidth, loss.
- Instrument test client (requests, Playwright, custom script) to apply shaping.
- Capture KPIs: TTFB, full load, error rate, retry count.
- Automate in CI/CD – either self‑hosted runner with
tcor provider API. - Reset/clean shaping after each job to avoid cross‑contamination.
- Log profile parameters alongside results for traceability.
By treating the proxy as a network condition injector rather than just an IP rotator, you gain a repeatable, programmable way to validate that your mobile apps and APIs survive the real world – before your users encounter the glitches.