Ý tưởng hoạt động
Speed test trên browser hoạt động dựa trên 3 bước đo thực tế:
- Ping test: Gửi HEAD request đến server, đo thời gian phản hồi (RTT). Lặp lại nhiều lần để tính jitter.
- Download test: Tải file lớn từ CDN (ví dụ: Cloudflare, jsDelivr), đo thời gian tải và tính Mbps.
- Upload test: Tạo blob dữ liệu ngẫu nhiên và POST lên server echo (httpbin.org), đo thời gian gửi.
Lưu ý: Kết quả phụ thuộc vào nhiều yếu tố (server load, network condition, browser throttling) nên có thể khác nhau giữa các lần test. Đây là speed test đơn giản dùng để demo kỹ năng front-end.
Cấu trúc HTML
Container chính chia thành 3 phase: ping → download → upload. UIkit đảm nhận layout và card styling.
<div class="uk-card uk-card-default uk-card-body">
<div id="speedtest">
<!-- Phase indicator -->
<div class="phase-bar">
<span class="phase active">Ping</span>
<span class="phase">Download</span>
<span class="phase">Upload</span>
</div>
<!-- Gauge meter -->
<div class="gauge-container">
<div class="gauge-needle"></div>
<div class="gauge-label">0 Mbps</div>
</div>
<!-- Stats grid -->
<div class="stats-grid">
<div class="stat">
<div class="stat-value">0</div>
<div class="stat-label">Ping (ms)</div>
</div>
<div class="stat">
<div class="stat-value">0.00</div>
<div class="stat-label">Download</div>
</div>
<div class="stat">
<div class="stat-value">0.00</div>
<div class="stat-label">Upload</div>
</div>
</div>
<!-- Progress -->
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<!-- Result -->
<div class="result-area">
<div class="speed-value">0.00</div>
<div class="speed-unit">Mbps</div>
</div>
<button class="start-btn">Bắt đầu test</button>
</div>
</div>
CSS animation cho gauge và phase
Gauge meter dùng conic-gradient 3 màu. Phase bar highlight từng bước. Stats grid dùng UIkit grid.
.phase-bar {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
}
.phase {
padding: 6px 16px;
border-radius: 20px;
background: #e5e5e5;
color: #999;
font-size: 0.85em;
font-weight: 600;
transition: all 0.3s;
}
.phase.active {
background: #1e87f0;
color: #fff;
}
.phase.done {
background: #32d296;
color: #fff;
}
.gauge-container {
width: 220px;
height: 110px;
margin: 0 auto 20px;
position: relative;
background: conic-gradient(
from 180deg at 50% 100%,
#32d296 0deg 60deg,
#1e87f0 60deg 120deg,
#faa05a 120deg 180deg
);
border-radius: 220px 220px 0 0;
mask: radial-gradient(circle at 50% 100%, transparent 65%, black 66%);
-webkit-mask: radial-gradient(circle at 50% 100%, transparent 65%, black 66%);
}
.gauge-needle {
position: absolute;
bottom: 0;
left: 50%;
width: 4px;
height: 100px;
background: #333;
transform-origin: bottom center;
transform: translateX(-50%) rotate(-90deg);
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 2px;
}
.gauge-label {
position: absolute;
bottom: -35px;
left: 50%;
transform: translateX(-50%);
font-weight: 700;
font-size: 1.2em;
color: #333;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 20px;
}
.stat {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-value {
font-size: 1.8em;
font-weight: 700;
color: #1e87f0;
line-height: 1;
}
.stat-label {
font-size: 0.8em;
color: #999;
margin-top: 5px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e5e5e5;
border-radius: 3px;
overflow: hidden;
margin-bottom: 20px;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #1e87f0, #32d296);
border-radius: 3px;
transition: width 0.5s ease;
}
.result-area {
text-align: center;
margin-bottom: 20px;
}
.speed-value {
font-size: 3.5em;
font-weight: 700;
color: #1e87f0;
line-height: 1;
}
.speed-unit {
font-size: 1.2em;
color: #999;
margin-top: 5px;
}
.start-btn {
display: block;
width: 100%;
padding: 16px;
border: none;
border-radius: 8px;
background: #1e87f0;
color: #fff;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.start-btn:hover {
background: #0f6ecd;
}
.start-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.pulse {
animation: pulse 1.2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
JavaScript test mạng thật
Logic chia thành 3 phase: ping (HEAD request), download (tải file từ CDN), upload (POST lên httpbin). Mỗi phase có delay giữa các lần đo để tạo cảm giác chân thực.
// File test từ CDN — đảm bảo CORS enabled
const DOWNLOAD_URLS = [
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js',
'https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js'
];
const UPLOAD_URL = 'https://httpbin.org/post';
const PING_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js';
async function runSpeedTest() {
resetUI();
// Phase 1: Ping
await runPingTest();
// Phase 2: Download
await runDownloadTest();
// Phase 3: Upload
await runUploadTest();
finishTest();
}
async function runPingTest() {
setPhase('ping');
const pings = [];
const count = 5;
for (let i = 0; i < count; i++) {
const start = performance.now();
try {
await fetch(PING_URL, { method: 'HEAD', cache: 'no-store' });
} catch (e) {
// ignore error, use timeout
}
const ping = performance.now() - start;
pings.push(ping);
updatePing(ping, i + 1, count);
await sleep(300); // delay giữa các lần ping
}
const avgPing = pings.reduce((a, b) => a + b, 0) / pings.length;
const jitter = Math.max(...pings) - Math.min(...pings);
document.getElementById('ping-value').textContent = Math.round(avgPing);
document.getElementById('jitter-value').textContent = Math.round(jitter);
markPhaseDone('ping');
}
async function runDownloadTest() {
setPhase('download');
const speeds = [];
for (let url of DOWNLOAD_URLS) {
const start = performance.now();
const response = await fetch(url, { cache: 'no-store' });
const reader = response.body.getReader();
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
const elapsed = (performance.now() - start) / 1000;
const speed = (loaded * 8) / (1024 * 1024 * elapsed);
updateGauge(speed, 'download');
}
const totalTime = (performance.now() - start) / 1000;
const finalSpeed = (loaded * 8) / (1024 * 1024 * totalTime);
speeds.push(finalSpeed);
await sleep(500);
}
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
document.getElementById('download-value').textContent = avgSpeed.toFixed(2);
markPhaseDone('download');
}
async function runUploadTest() {
setPhase('upload');
const sizes = [0.5, 1, 2]; // MB
const speeds = [];
for (let sizeMB of sizes) {
const data = createUploadData(sizeMB * 1024 * 1024);
const start = performance.now();
try {
await fetch(UPLOAD_URL, {
method: 'POST',
body: data,
headers: { 'Content-Type': 'application/octet-stream' }
});
} catch (e) {
// httpbin có thể chậm, dùng giá trị ước tính
}
const elapsed = (performance.now() - start) / 1000;
const speed = (sizeMB * 8) / elapsed;
speeds.push(speed);
updateGauge(speed, 'upload');
await sleep(500);
}
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
document.getElementById('upload-value').textContent = avgSpeed.toFixed(2);
markPhaseDone('upload');
}
function createUploadData(size) {
const chunk = new Uint8Array(1024);
for (let i = 0; i < chunk.length; i++) chunk[i] = Math.random() * 256;
const chunks = Math.ceil(size / 1024);
return new Blob(Array(chunks).fill(chunk));
}
function updateGauge(speed, type) {
const needle = document.querySelector('.gauge-needle');
const valueEl = document.querySelector('.speed-value');
const labelEl = document.querySelector('.gauge-label');
const fill = document.querySelector('.progress-fill');
const maxSpeed = type === 'ping' ? 100 : 200;
const angle = -90 + (Math.min(speed, maxSpeed) / maxSpeed) * 180;
needle.style.transform = 'translateX(-50%) rotate(' + angle + 'deg)';
valueEl.textContent = speed.toFixed(2);
labelEl.textContent = speed.toFixed(1) + ' Mbps';
// Màu theo tốc độ
if (speed < 10) valueEl.style.color = '#f0506e';
else if (speed < 50) valueEl.style.color = '#faa05a';
else valueEl.style.color = '#32d296';
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Demo
Download
Upload
Ưu điểm của cách làm này
- Test trên mạng thật — tải file từ CDN, không dùng blob local.
- 3 phase đầy đủ: Ping → Download → Upload, giống Speedtest.net.
- Delay giữa các lần đo tạo cảm giác chân thực, không chạy quá nhanh.
- Gauge meter xoay mượt với cubic-bezier, không giật cục.
- Không cần backend riêng — dùng CDN công khai và httpbin.org.
- Dễ tích hợp vào landing page, dashboard, hoặc trang hosting.
Cách tối ưu hiệu ứng
- Thay
DOWNLOAD_URLSbằng file lớn hơn (5-10MB) nếu mạng nhanh — đo chính xác hơn. - Thêm
AbortController</strong> để user có thể hủy test giữa chừng.</li>thay
<li>Dùng <code>performance.now()Date.now()để độ chính xác cao hơn (microsecond). - Cache-busting bằng
cache: 'no-store'hoặc thêm timestamp vào URL. - Fallback khi CDN chậm: hiển thị “Đang kết nối…” và timeout sau 10 giây.
Khi nào nên dùng speed test
Speed test đơn giản rất hợp cho các tình huống:
- Landing page hosting/VPN — khoe tốc độ server và network.
- Dashboard admin — kiểm tra network condition của user real-time.
- Portfolio front-end — demo kỹ năng JavaScript async + animation.
- Trang hỗ trợ — giúp user self-diagnose vấn đề mạng trước khi gọi support.
Dùng đúng chỗ thì “wow”, dùng sai chỗ thì thành spam. Cứ nhớ nguyên tắc đó là ổn.
Kết luận
Chỉ với HTML, CSS animation và JavaScript thuần, bạn đã có speed test chạy trên mạng thật — đo ping, download, upload — đẹp, gọn và dễ kiểm soát. Không cần thư viện nặng, không cần backend riêng, vẫn đủ để gây ấn tượng mạnh cho người xem.
Copy code, dán vào project, chỉnh CDN endpoint theo nhu cầu là chơi được ngay.
Bình luận