Skip to content

Commit 0723178

Browse files
fix: adaptive detection uses latency probe instead of bandwidth measurement
Root cause: small probe files (~1KB) give inaccurate bandwidth readings because HTTP overhead dominates. A favicon fetched in 15ms on gigabit calculates to 0.5Mbps — causing false downgrades on fast connections. Fix: measure LATENCY (round-trip time) instead of bandwidth: - <300ms round-trip → premium (motion 3) - 300-1000ms → standard (motion 2) - 1000-3000ms → standard (motion 1) - >3000ms → lite (motion 0) Dual-signal approach: takes the WORSE of navigator.connection and latency probe. This correctly handles: - Real 4G phone (API=4g, probe=fast) → premium ✓ - DevTools GPRS throttle (API=4g, probe=slow) → lite ✓ - Real 2G (API=2g, probe=slow) → lite ✓ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0e9c025 commit 0723178

2 files changed

Lines changed: 106 additions & 113 deletions

File tree

demo/public/adaptive-test.html

Lines changed: 68 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,11 @@ <h1>Adaptive Tier Test</h1>
127127
<span class="info-value" id="info-motion"></span>
128128
</div>
129129
<div class="info-row">
130-
<span class="info-label">Measured Speed</span>
130+
<span class="info-label">Probe Round-Trip</span>
131131
<span class="info-value" id="info-speed">measuring...</span>
132132
</div>
133133
<div class="info-row">
134-
<span class="info-label">Probe Latency</span>
134+
<span class="info-label">Probe Latency (raw)</span>
135135
<span class="info-value" id="info-latency"></span>
136136
</div>
137137
<div class="info-row">
@@ -199,110 +199,108 @@ <h2 style="margin-top: 0">How This Works</h2>
199199
</div>
200200

201201
<script>
202-
// ─── Speed Probe: measure actual download speed ───────────────────
203-
// Downloads a small resource and measures how long it takes.
204-
// This works with DevTools throttling (unlike navigator.connection).
202+
// ─── Dual detection: navigator.connection + latency probe ────────
203+
//
204+
// Strategy:
205+
// 1. navigator.connection gives the network TYPE (4g/3g/2g) — accurate on real devices
206+
// 2. Latency probe measures round-trip time — works with DevTools throttling
207+
// 3. Take the WORSE of the two — handles both real slow connections AND simulated throttling
205208

206-
async function measureSpeed() {
209+
async function measureLatency() {
207210
const progress = document.getElementById('probe-progress')
208211
progress.style.width = '30%'
209-
210212
try {
211-
// Use a cache-busted fetch of this page itself as a probe
212-
// The page is ~12KB — enough to measure, small enough for GPRS
213-
const probeUrl = window.location.href + '?probe=' + Date.now()
213+
const url = window.location.href.split('?')[0] + '?probe=' + Date.now()
214214
const start = performance.now()
215-
const response = await fetch(probeUrl, { cache: 'no-store' })
216-
const blob = await response.blob()
215+
await fetch(url, { cache: 'no-store' })
217216
const elapsed = performance.now() - start
218-
const bytes = blob.size
219-
const bitsPerSecond = (bytes * 8) / (elapsed / 1000)
220-
const mbps = bitsPerSecond / 1_000_000
221-
222217
progress.style.width = '100%'
223-
224-
return {
225-
mbps: Math.round(mbps * 100) / 100,
226-
latencyMs: Math.round(elapsed),
227-
bytes: bytes,
228-
method: 'speed-probe'
229-
}
230-
} catch (e) {
218+
return Math.round(elapsed)
219+
} catch {
231220
progress.style.width = '100%'
232-
return { mbps: -1, latencyMs: -1, bytes: 0, method: 'probe-failed' }
221+
return -1
233222
}
234223
}
235224

236-
// ─── Tier Detection ──────────────────────────────────────────────
225+
function tierFromLatency(ms) {
226+
if (ms < 300) return { tier: 'premium', motion: 3, reason: 'Fast probe: ' + ms + 'ms' }
227+
if (ms < 1000) return { tier: 'standard', motion: 2, reason: 'Moderate probe: ' + ms + 'ms' }
228+
if (ms < 3000) return { tier: 'standard', motion: 1, reason: 'Slow probe: ' + ms + 'ms' }
229+
return { tier: 'lite', motion: 0, reason: 'Very slow probe: ' + ms + 'ms' }
230+
}
231+
232+
function tierFromConnection(conn) {
233+
if (conn.saveData) return { tier: 'lite', motion: 0, reason: 'Save-Data enabled' }
234+
var type = conn.effectiveType
235+
var dl = conn.downlink || 10
236+
var rtt = conn.rtt || 50
237+
if (type === 'slow-2g' || type === '2g') return { tier: 'lite', motion: 0, reason: 'Connection: ' + type }
238+
if (type === '3g' && dl < 0.5) return { tier: 'standard', motion: 1, reason: 'Slow 3G: ' + dl + 'Mbps' }
239+
if (type === '3g') return { tier: 'standard', motion: 2, reason: '3G: ' + dl + 'Mbps' }
240+
if (dl >= 1 || rtt < 100) return { tier: 'premium', motion: 3, reason: 'Fast: ' + dl + 'Mbps / ' + rtt + 'ms' }
241+
if (dl >= 0.4) return { tier: 'standard', motion: 2, reason: 'Moderate: ' + dl + 'Mbps' }
242+
return { tier: 'standard', motion: 1, reason: 'Slow 4G: ' + dl + 'Mbps' }
243+
}
237244

238245
async function detect() {
239-
const badge = document.getElementById('tier-badge')
240-
badge.textContent = '⏳ Measuring speed...'
246+
var badge = document.getElementById('tier-badge')
247+
badge.textContent = '⏳ Detecting...'
241248
badge.setAttribute('data-tier', 'standard')
242249

243-
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection
250+
var conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection
244251

245-
// Quick checks first (no network needed)
252+
// Quick OS-level checks
246253
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
247-
return applyResult('lite', 0, 'prefers-reduced-motion', 'os-preference', null, conn)
254+
return applyResult('lite', 0, 'prefers-reduced-motion', 'os-preference', -1, conn)
248255
}
249256
if (conn && conn.saveData) {
250-
return applyResult('lite', 0, 'Save-Data enabled', 'save-data', null, conn)
257+
return applyResult('lite', 0, 'Save-Data enabled', 'save-data', -1, conn)
251258
}
252259

253-
// Measure actual speed
254-
const speed = await measureSpeed()
260+
// Get both signals
261+
var connResult = conn && conn.effectiveType ? tierFromConnection(conn) : null
262+
var latencyMs = await measureLatency()
263+
var probeResult = latencyMs >= 0 ? tierFromLatency(latencyMs) : null
255264

256-
if (speed.mbps < 0) {
257-
// Probe failed — fall back to navigator.connection
258-
if (conn && conn.effectiveType) {
259-
const type = conn.effectiveType
260-
if (type === 'slow-2g' || type === '2g') return applyResult('lite', 0, 'Connection API: ' + type, 'connection-api', speed, conn)
261-
if (type === '3g') return applyResult('standard', 2, 'Connection API: 3G', 'connection-api', speed, conn)
262-
return applyResult('premium', 3, 'Connection API: ' + type, 'connection-api', speed, conn)
263-
}
264-
return applyResult('standard', 2, 'Could not measure speed', 'fallback', speed, conn)
265-
}
265+
var tier, motion, reason, method
266266

267-
// Classify based on ACTUAL measured speed
268-
let tier, motion, reason
269-
if (speed.mbps >= 2) {
270-
tier = 'premium'; motion = 3
271-
reason = 'Fast: ' + speed.mbps + ' Mbps'
272-
} else if (speed.mbps >= 0.5) {
273-
tier = 'standard'; motion = 2
274-
reason = 'Moderate: ' + speed.mbps + ' Mbps'
275-
} else if (speed.mbps >= 0.1) {
276-
tier = 'standard'; motion = 1
277-
reason = 'Slow: ' + speed.mbps + ' Mbps'
267+
if (connResult && probeResult) {
268+
// Take the WORSE of the two signals
269+
if (probeResult.motion <= connResult.motion) {
270+
tier = probeResult.tier; motion = probeResult.motion
271+
reason = probeResult.reason + ' | API: ' + connResult.reason
272+
method = 'probe (downgraded from API)'
273+
} else {
274+
tier = connResult.tier; motion = connResult.motion
275+
reason = connResult.reason + ' (probe: ' + latencyMs + 'ms)'
276+
method = 'connection-api (confirmed by probe)'
277+
}
278+
} else if (probeResult) {
279+
tier = probeResult.tier; motion = probeResult.motion
280+
reason = probeResult.reason; method = 'probe-only'
281+
} else if (connResult) {
282+
tier = connResult.tier; motion = connResult.motion
283+
reason = connResult.reason; method = 'connection-api-only'
278284
} else {
279-
tier = 'lite'; motion = 0
280-
reason = 'Very slow: ' + speed.mbps + ' Mbps'
285+
tier = 'standard'; motion = 2; reason = 'No signals'; method = 'fallback'
281286
}
282287

283-
// Cross-reference with connection API if available
284-
if (conn && (conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g')) {
285-
tier = 'lite'; motion = 0
286-
reason += ' (confirmed by connection API: ' + conn.effectiveType + ')'
287-
}
288-
289-
applyResult(tier, motion, reason, speed.method, speed, conn)
288+
applyResult(tier, motion, reason, method, latencyMs, conn)
290289
}
291290

292-
function applyResult(tier, motion, reason, method, speed, conn) {
291+
function applyResult(tier, motion, reason, method, latencyMs, conn) {
293292
setTier(tier)
294-
295293
document.getElementById('info-tier').textContent = tier
296294
document.getElementById('info-motion').textContent = motion
297-
document.getElementById('info-speed').textContent = speed ? (speed.mbps >= 0 ? speed.mbps + ' Mbps' : 'failed') : 'skipped'
298-
document.getElementById('info-latency').textContent = speed ? (speed.latencyMs >= 0 ? speed.latencyMs + ' ms' : '—') : '—'
295+
document.getElementById('info-speed').textContent = latencyMs >= 0 ? latencyMs + 'ms round-trip' : 'skipped'
296+
document.getElementById('info-latency').textContent = latencyMs >= 0 ? latencyMs + ' ms' : '—'
299297
document.getElementById('info-conn').textContent = conn ? (conn.effectiveType + ' / ' + (conn.downlink || '?') + ' Mbps / ' + (conn.rtt || '?') + 'ms RTT') : 'unavailable'
300298
document.getElementById('info-savedata').textContent = conn ? (conn.saveData ? 'Yes' : 'No') : '—'
301299
document.getElementById('info-reason').textContent = reason
302300
document.getElementById('info-method').textContent = method
303301

304-
const badge = document.getElementById('tier-badge')
305-
const icon = tier === 'premium' ? '✨' : tier === 'standard' ? '⚡' : '🪶'
302+
var badge = document.getElementById('tier-badge')
303+
var icon = tier === 'premium' ? '✨' : tier === 'standard' ? '⚡' : '🪶'
306304
badge.textContent = icon + ' ' + tier.toUpperCase()
307305
badge.setAttribute('data-tier', tier)
308306
}
@@ -311,7 +309,6 @@ <h2 style="margin-top: 0">How This Works</h2>
311309
document.getElementById('app').setAttribute('data-tier', tier)
312310
}
313311

314-
// Run on load
315312
detect()
316313
document.getElementById('timestamp').textContent = 'Page loaded: ' + new Date().toLocaleTimeString()
317314
</script>

src/core/adaptive/use-adaptive-tier.ts

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -132,41 +132,35 @@ export function detectAdaptiveTier(): AdaptiveResult {
132132
}
133133

134134
/**
135-
* Measure actual download speed by fetching a small resource.
135+
* Measure actual network latency by fetching a small resource.
136136
* This works with DevTools throttling (unlike navigator.connection).
137-
* Returns measured Mbps, or -1 if probe fails.
137+
*
138+
* Instead of measuring bandwidth (which is inaccurate with small files),
139+
* we measure LATENCY — how long a round-trip takes. This is a much
140+
* better signal for small probe files:
141+
* - Fast connection: <100ms round-trip
142+
* - Moderate: 100-500ms
143+
* - Slow (3G): 500-2000ms
144+
* - Very slow (GPRS): >2000ms
138145
*/
139-
async function measureActualSpeed(): Promise<{ mbps: number; latencyMs: number }> {
146+
async function measureActualLatency(): Promise<{ latencyMs: number }> {
140147
try {
141-
// Fetch a small cacheable resource with cache-bust to measure real speed
142-
// Use a 1x1 transparent GIF data URL approach — generate a ~1KB payload
143148
const probeUrl = `${window.location.origin}/favicon.ico?_probe=${Date.now()}`
144149
const start = performance.now()
145-
const response = await fetch(probeUrl, { cache: 'no-store', mode: 'no-cors' })
146-
// Even with no-cors/opaque response, the timing tells us latency
150+
await fetch(probeUrl, { cache: 'no-store', mode: 'no-cors' })
147151
const elapsed = performance.now() - start
148-
149-
// Try to get actual size from response
150-
let bytes = 1024 // assume ~1KB if we can't read
151-
try {
152-
const blob = await response.blob()
153-
bytes = blob.size || 1024
154-
} catch { /* opaque response, use estimate */ }
155-
156-
const bitsPerSecond = (bytes * 8) / (elapsed / 1000)
157-
const mbps = bitsPerSecond / 1_000_000
158-
159-
return { mbps: Math.round(mbps * 100) / 100, latencyMs: Math.round(elapsed) }
152+
return { latencyMs: Math.round(elapsed) }
160153
} catch {
161-
return { mbps: -1, latencyMs: -1 }
154+
return { latencyMs: -1 }
162155
}
163156
}
164157

165-
function tierFromMeasuredSpeed(mbps: number): AdaptiveResult {
166-
if (mbps >= 2) return { tier: 'premium', motion: 3, confidence: 'high', reason: `Measured: ${mbps} Mbps` }
167-
if (mbps >= 0.5) return { tier: 'standard', motion: 2, confidence: 'high', reason: `Measured: ${mbps} Mbps` }
168-
if (mbps >= 0.1) return { tier: 'standard', motion: 1, confidence: 'high', reason: `Measured slow: ${mbps} Mbps` }
169-
return { tier: 'lite', motion: 0, confidence: 'high', reason: `Measured very slow: ${mbps} Mbps` }
158+
function tierFromLatency(latencyMs: number): AdaptiveResult {
159+
// Latency-based classification is more reliable than bandwidth for small probes
160+
if (latencyMs < 300) return { tier: 'premium', motion: 3, confidence: 'high', reason: `Fast probe: ${latencyMs}ms` }
161+
if (latencyMs < 1000) return { tier: 'standard', motion: 2, confidence: 'high', reason: `Moderate probe: ${latencyMs}ms` }
162+
if (latencyMs < 3000) return { tier: 'standard', motion: 1, confidence: 'high', reason: `Slow probe: ${latencyMs}ms` }
163+
return { tier: 'lite', motion: 0, confidence: 'high', reason: `Very slow probe: ${latencyMs}ms` }
170164
}
171165

172166
/**
@@ -186,33 +180,35 @@ export function useAdaptiveTier(override?: AdaptiveTier): AdaptiveResult {
186180
return detectAdaptiveTier()
187181
})
188182

189-
// Async speed probe for refinement — catches DevTools throttling
183+
// Async latency probe for refinement — catches DevTools throttling
190184
useEffect(() => {
191185
if (override) return
192186
if (typeof window === 'undefined') return
193187

194188
let cancelled = false
195189

196-
// Start with synchronous detection
190+
// Start with synchronous detection (navigator.connection + TTFB)
197191
const initial = detectAdaptiveTier()
198192
setResult(initial)
199193

200-
// Then refine with actual measurement
201-
measureActualSpeed().then(({ mbps }) => {
194+
// Then refine with actual round-trip measurement
195+
measureActualLatency().then(({ latencyMs }) => {
202196
if (cancelled) return
203-
if (mbps < 0) return // probe failed, keep initial detection
204-
205-
const refined = tierFromMeasuredSpeed(mbps)
206-
207-
// Only downgrade, never upgrade from initial
208-
// This prevents a premium page from flickering to lite
209-
// But if the measured speed is slower, we should respect it
210-
if (refined.motion < initial.motion) {
211-
setResult(refined)
212-
}
213-
// If measured speed confirms or upgrades, keep it
214-
else {
215-
setResult(prev => ({ ...prev, confidence: 'high', reason: `${prev.reason} (confirmed: ${mbps} Mbps)` }))
197+
if (latencyMs < 0) return // probe failed, keep initial detection
198+
199+
const probed = tierFromLatency(latencyMs)
200+
201+
// Trust the WORSE of the two signals
202+
// This correctly handles:
203+
// - Real 4G (API=premium, probe=premium) → premium ✓
204+
// - DevTools GPRS (API=premium, probe=lite) → lite ✓
205+
// - Real slow connection (API=3g, probe=slow) → takes worse ✓
206+
if (probed.motion < initial.motion) {
207+
// Probe detected slower than API — real throttling, downgrade
208+
setResult(probed)
209+
} else {
210+
// Probe confirms or is faster — trust initial + mark confirmed
211+
setResult(prev => ({ ...prev, confidence: 'high', reason: `${prev.reason} (confirmed: ${latencyMs}ms probe)` }))
216212
}
217213
})
218214

0 commit comments

Comments
 (0)