Mọi bài hướng dẫn về fetch trong Node.js đều bảo bạn chỉ cần await fetch(url) là xong. Rồi đến khi vào môi trường production, ứng dụng của bạn lặng lẽ nuốt mất lỗi 500, một request bị treo 90 giây mà không có timeout, và bạn phải dành cả tối thứ Sáu để gỡ một vấn đề đáng ra phải rất rõ ràng.
Tôi đã xây dựng các công cụ nội bộ và pipeline dữ liệu tại Thunderbit khá lâu rồi, và tôi có thể nói với bạn rằng: khoảng cách giữa “fetch chạy được trong tutorial” và “fetch chạy được trong production” chính là nơi phát sinh hầu hết nỗi đau. Một developer trên Reddit đã nói rất đúng: “khi bạn đưa lên production, bạn mới nhận ra mình cần thứ gì đó bền bỉ hơn fetch gốc.”
Một người khác thú nhận: “Làm web developer 3 năm rồi, hôm nay tôi mới biết khối catch của fetch API KHÔNG dành cho lỗi HTTP.” Hướng dẫn này sẽ đi qua năm điều mà hầu hết tutorial bỏ qua — bẫy lỗi, timeout với AbortController, logic retry, tái sử dụng kết nối, và khi nào nên đi xa hơn fetch để trích xuất dữ liệu có cấu trúc. Nếu bạn từng thấy một lệnh fetch thất bại âm thầm trong production, bài này dành cho bạn.

Node.js Fetch API là gì?
Node.js Fetch API là cách tích hợp sẵn, tương thích với browser để gửi yêu cầu HTTP (GET, POST, PUT, DELETE, v.v.) từ Node.js — mà không cần cài Axios, node-fetch hay bất kỳ package nào khác. Nếu bạn đã dùng fetch() trong trình duyệt, bạn đã biết cú pháp rồi. Giờ đây cùng một API đó cũng chạy được ở phía server.
Đây là bản tóm tắt nhanh về lịch sử phiên bản:
| Mốc | Phiên bản Node | Điều đã xảy ra |
|---|---|---|
| Cờ fetch thử nghiệm | v17.5.0 / v16.15.0 | fetch được thêm sau cờ --experimental-fetch |
| Global fetch mặc định | v18.0.0 | fetch thử nghiệm khả dụng toàn cục, chạy trên Undici |
| Fetch ổn định | v21.0.0 | Không còn là thử nghiệm nữa |
| Nền tảng production 2026 | v22 LTS / v24 LTS | Khuyến nghị cho production; v20 hiện đã EOL |
Ở tầng bên dưới, fetch của Node được hỗ trợ bởi Undici — một HTTP client hiệu năng cao được xây riêng cho Node.js. Nó không dựa vào module http cũ tích hợp sẵn. Lợi ích thực tế: bạn có một API HTTP hiện đại, dựa trên Promise, hoạt động giống nhau trong code trình duyệt, backend Express, serverless function và cả script CLI.
Vì sao Node.js Fetch API quan trọng với dự án của bạn
Trước Node 18, mỗi dự án mới thường bắt đầu bằng cùng một nghi thức: npm install axios hoặc npm install node-fetch. Đến năm 2026, nếu dự án của bạn chạy trên một bản Node LTS còn được bảo trì, các request HTTP cơ bản không cần bất kỳ dependency nào. Đó là một lợi thế thật sự cho kích thước bundle, an toàn chuỗi cung ứng, và việc onboarding (developer front-end và back-end cuối cùng cũng dùng chung một API).
Đây là những nơi fetch gốc phát huy sức mạnh:
| Tình huống | Vì sao fetch gốc phù hợp | Lưu ý khi production |
|---|---|---|
| Backend Express/Fastify gọi REST API | Async/await quen thuộc, không phụ thuộc gói ngoài | Thêm timeout và kiểm tra response.ok |
| Serverless function (Lambda, Vercel, v.v.) | Ít làm nặng cold start, không cần cài package | Giữ timeout thấp hơn mức tối đa của nền tảng |
| Script CLI và automation | GET/POST đơn giản, không cần setup dự án | Thêm retry/backoff cho API chập chờn |
| Gửi hoặc chuyển tiếp webhook | Method và header HTTP chuẩn | Đừng retry mù quáng với POST không idempotent |
| Báo cáo và dashboard | Tốt cho việc kéo JSON từ API | Dùng pagination và connection pooling cho vòng lặp |
| Giao tiếp microservice | Hợp cho các cuộc gọi HTTP nội bộ đơn giản | Cân nhắc dùng trực tiếp Got hoặc Undici cho retry, hooks, hoặc HTTP/2 |
Với dự án Node 22+ mới, fetch gốc là lựa chọn mặc định hợp lý — trừ khi bạn biết mình cần những tính năng nó không có (interceptor, retry tích hợp, HTTP/2, v.v.). Số lượt tải npm kể một câu chuyện về bối cảnh đang chuyển dịch: node-fetch vẫn có ~144,9 triệu lượt tải mỗi tuần, nhưng phần lớn là legacy và dependency gián tiếp. Axios ở mức ~108,6 triệu, Undici ~106 triệu, Got ~36 triệu, và Ky ~5,6 triệu. Xu hướng rất rõ: fetch gốc là mặt bằng mới, còn client bên thứ ba dành cho nhu cầu cụ thể.
Native Fetch vs node-fetch vs Axios vs Got vs Ky: Ma trận quyết định 2026
Câu hỏi phổ biến nhất tôi thấy trên các diễn đàn developer: “Tôi nên dùng HTTP client nào trong Node.js?” Một người dùng Reddit đã tóm gọn rất hay: “Tại sao phải import một thư viện… khi ngôn ngữ/framework đã có sẵn chức năng đó?” Quan điểm hợp lý — nhưng câu trả lời còn tùy bạn cần gì.

| Tính năng | fetch gốc | node-fetch v3 | axios | got v15 | ky v2 |
|---|---|---|---|---|---|
| Phiên bản Node.js | ≥18 (khuyến nghị 22/24 LTS) | ≥12.20 | Rộng | ≥22 | ≥22 |
| Có cần cài đặt không | Không | Có | Có | Có | Có |
| Hỗ trợ ESM + CJS | Cả hai (global) | Chỉ ESM (v3) | Cả hai | Chỉ ESM | Chỉ ESM |
| Tự reject với 4xx/5xx | Không | Không | Có | Có | Có |
| Retry tích hợp | Không | Không | Không | Có | Có |
| Request interceptor | Không | Không | Có | Có (hooks) | Có (hooks) |
| Hỗ trợ streaming | Web ReadableStream | Có | Hạn chế | Node streams mạnh | Dựa trên fetch |
| Dấu chân bundle/cài đặt | 0 KB | ~107 KB, 3 deps | ~2,8 MB, 4 deps | ~355 KB, 12 deps | ~405 KB, 0 deps |
| Hỗ trợ HTTP/2 | Qua Undici dispatcher | Không | Không | Có | Không (fetch wrapper) |
Một ghi chú nhanh về nỗi đau ESM/CJS: node-fetch v3 chỉ hỗ trợ ESM, điều này đã phá vỡ nhiều dự án dùng require(). Fetch gốc là global — nó chạy được trong cả file CJS lẫn ESM mà không cần trò import rườm rà. Nếu bạn đang mắc kẹt với node-fetch v2 vì CommonJS, fetch gốc giải quyết triệt để vấn đề đó.
Còn về những lo ngại ban đầu khi nó mới ra: đúng là Node 18 từng có bug thật trong bản triển khai fetch ban đầu. Một developer trên Reddit từng nói: “Gần đây tôi gặp một bug rất kỳ quặc với native fetch trên Node 18 nên phải chuyển app.” Đó là chuyện của năm 2023. Đến 2026, với Node 22 và 24 LTS, các vấn đề đó đã được xử lý. Fetch gốc đã sẵn sàng cho production.
Khi nào nên giữ fetch gốc
Hãy dùng fetch gốc khi:
- Dự án của bạn chạy trên Node 22 LTS hoặc Node 24 LTS.
- Request của bạn là các lệnh REST đơn giản (GET, POST, PUT, DELETE).
- Bạn sẵn sàng thêm một wrapper nhỏ cho
response.ok, parse JSON, timeout và retry. - Bạn muốn bề mặt dependency bằng 0 và ít lo ngại hơn về chuỗi cung ứng.
- Bạn coi trọng việc API giữa browser và server tương đồng nhau.
- Bạn làm trong môi trường serverless hoặc edge, nơi API tích hợp sẵn thường được ưu tiên.
Khi nào Axios, Got hoặc Ky hợp lý hơn
Axios là lựa chọn đúng khi team của bạn dựa vào interceptor request/response (ví dụ: tự động refresh token xác thực, header tenant, logging tập trung), khi bạn muốn mặc định reject với lỗi HTTP, hoặc khi cần tương thích ngược với các runtime Node cũ.
Got được xây cho các service Node có lưu lượng cao cần retry tích hợp sẵn, hooks, các pha timeout nâng cao, streams, trợ giúp pagination, Unix socket, workflow proxy/cache, hoặc hỗ trợ HTTP/2. Nó là con dao đa năng kiểu Thụy Sĩ cho công việc HTTP chỉ chạy trên Node.
Ky là điểm ngọt nếu bạn thích sự đơn giản của fetch nhưng muốn ít boilerplate hơn — nó bổ sung retry, timeout, hooks và HTTPError trong một package rất nhỏ, không có dependency nào.
Cách gửi GET request bằng Node.js Fetch API
Một GET request với async/await sẽ như sau:
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const post = await response.json();
console.log(post.title);
// → "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
Và nếu bạn thích dạng chain .then():
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => response.json())
.then(post => console.log(post.title))
.catch(error => console.error(error));
Cả hai đều chạy được. Nhưng chưa cái nào an toàn cho production cả (chúng ta sẽ nói kỹ hơn ngay sau đây).
Những cách đọc response bạn nên biết:
| Phương thức | Dùng khi |
|---|---|
response.json() | Server trả về JSON |
response.text() | Server trả về HTML, văn bản thuần, CSV, Markdown |
response.arrayBuffer() | Bạn cần dữ liệu nhị phân (ảnh, file) |
response.body | Bạn cần xử lý streaming/chunked |
Một pattern tốt hơn — có kiểm tra lỗi thật sự:
async function getPost(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
return response.json();
}
const post = await getPost(1);
console.log(post.title);
Dòng if (!response.ok) chính là ranh giới giữa tutorial và code production. Và đó cũng là bẫy lớn nhất.
Cách gửi POST request bằng Node.js Fetch API
POST request có cấu trúc gần như y hệt — chỉ cần đặt method, headers và body:
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Hướng dẫn Node fetch',
body: 'Fetch trong production cần xử lý lỗi.',
userId: 1,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const created = await response.json();
console.log(created.id); // → 101
Gửi các loại request khác (PUT, DELETE, PATCH)
PUT, PATCH và DELETE dùng cấu trúc giống hệt, chỉ khác giá trị method:
// PUT — thay thế toàn bộ
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 1, title: 'Đã thay thế', body: 'Thay thế toàn bộ', userId: 1 }),
});
// PATCH — cập nhật một phần
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Cập nhật một phần' }),
});
// DELETE
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'DELETE',
});
Bẫy body-parser trong Express: Nếu bạn POST JSON vào server Express mà req.body lại trả về undefined, cách sửa gần như luôn là: dùng express.json(), không phải express.urlencoded(). Server cần middleware express.json() trước route của bạn để phân tích body Content-Type: application/json. Đây là một trong những câu hỏi Stack Overflow phổ biến nhất về Express, và nó cứ khiến người ta vấp đi vấp lại.
import express from 'express';
const app = express();
app.use(express.json()); // ← Đây là cái bạn cần cho JSON POST body
app.post('/api/posts', (req, res) => {
res.json({ received: req.body });
});
Bẫy lỗi của fetch() làm hỏng ứng dụng production

Đây là nguồn gốc của hầu hết lỗi fetch trong production.
fetch() không reject promise khi gặp lỗi HTTP 4xx hoặc 5xx. Nó chỉ reject khi lỗi ở mức mạng — lỗi DNS, mất kết nối internet, request bị huỷ. Nếu server trả về 403 Forbidden hoặc 500 Internal Server Error, fetch vẫn coi đó là một response thành công. Khối .catch() của bạn sẽ không bao giờ chạy. try/catch của bạn cũng không bắt được. Code của bạn sẽ vui vẻ xử lý mọi thứ server trả về.
MDN nói rất rõ điều này, nhưng phần lớn tutorial lại lướt qua. Kết quả là gì? Những đoạn code như dưới đây nhìn có vẻ ổn nhưng lại âm thầm nuốt lỗi:
try {
const response = await fetch('https://api.example.com/private');
const data = await response.json(); // ← Dòng này vẫn chạy ngay cả khi là 403
console.log('Trông có vẻ thành công:', data);
} catch (error) {
// Chỉ lỗi ở mức mạng mới rơi vào đây
console.error('Đã bắt được:', error);
}
Phân tích nhanh xem mỗi pattern thực sự bắt được gì:
| Pattern | Bắt lỗi mạng | Bắt 4xx/5xx | Parse JSON an toàn | Tái sử dụng được |
|---|---|---|---|---|
Raw .then(res => res.json()) | Có (qua .catch()) | Không | Không có kiểm tra content-type | Không |
try/catch với await fetch() | Có | Không | Không có kiểm tra content-type | Không |
Tự if (!res.ok) ở từng chỗ | Có | Có | Tùy từng chỗ gọi | Một phần |
Wrapper fetchJSON() tùy biến | Có | Có | Có | Có |
Xây một wrapper fetchJSON() có thể tái sử dụng
Xây một wrapper thôi. Import nó ở mọi nơi. Đừng copy-paste if (!response.ok) vào từng file nữa:
export class HTTPError extends Error {
constructor(message, { status, statusText, url, body }) {
super(message);
this.name = 'HTTPError';
this.status = status;
this.statusText = statusText;
this.url = url;
this.body = body;
}
}
export async function fetchJSON(url, options = {}) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
...options.headers,
},
...options,
});
const contentType = response.headers.get('content-type') || '';
const isJSON = contentType.includes('application/json');
const body = isJSON ? await response.json().catch(() => null) : await response.text();
if (!response.ok) {
throw new HTTPError(`HTTP ${response.status} ${response.statusText}`, {
status: response.status,
statusText: response.statusText,
url: response.url,
body,
});
}
return body;
}
Bây giờ, khi server trả về 403:
try {
const data = await fetchJSON('https://api.example.com/private');
} catch (error) {
if (error instanceof HTTPError) {
console.error(`Server trả về ${error.status}:`, error.body);
} else {
console.error('Lỗi mạng hoặc lỗi khác:', error);
}
}
Lỗi sẽ mang theo mã trạng thái, body response và URL — đủ mọi thứ bạn cần cho logging, cảnh báo, hoặc thông báo gửi tới người dùng. Import một lần, dùng ở mọi nơi.
AbortController và Timeout: Pattern production cho Node.js Fetch API

Không có timeout, một lệnh fetch có thể treo vô thời hạn khi server từ xa im lặng. Route Express của bạn bị chặn. Lambda của bạn đốt hết ngân sách thời gian chạy. Script của bạn chỉ... ngồi đó.
Tôi đã kiểm tra các kết quả tìm kiếm hàng đầu: không có tutorial fetch nào viết riêng cho Node.js mà đề cập tới huỷ request hoặc timeout. Trong khi đó, timeout lại là một trong những lý do hàng đầu khiến developer vẫn bám lấy Axios hoặc Got. Một thread trên Reddit còn có đúng tựa đề “Node fetch does not timeout”.
Dùng AbortSignal.timeout() (Node 18.11+)
Cách đơn giản nhất — chỉ thêm một option:
try {
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // 5 giây
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error('Request đã quá thời gian sau 5 giây.');
} else {
throw error;
}
}
Lưu ý: AbortSignal.timeout() ném ra TimeoutError, không phải AbortError. Đây là chi tiết mà ngay cả một số developer có kinh nghiệm cũng hay nhầm.
Timeout thủ công với AbortController
Khi bạn cần kiểm soát nhiều hơn — hoặc muốn huỷ request dựa trên hành động của người dùng chứ không chỉ theo đồng hồ:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://api.example.com/data', {
signal: controller.signal,
});
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.error('Request đã bị huỷ thủ công.');
} else {
throw error;
}
} finally {
clearTimeout(timeout);
}
Xử lý AbortError vs TimeoutError
Sự khác biệt này quan trọng cho logging và thông báo gửi tới người dùng:
| Đường huỷ | Tên lỗi trong khối catch |
|---|---|
AbortSignal.timeout(ms) | TimeoutError |
controller.abort() | AbortError |
| Lỗi DNS/mạng | Thường là TypeError: fetch failed |
Đây là một tình huống thực tế — một route Express gọi API bên ngoài và phải phản hồi trong 3 giây:
app.get('/dashboard', async (req, res, next) => {
try {
const data = await fetchJSON('https://api.example.com/report', {
signal: AbortSignal.timeout(3000),
});
res.json(data);
} catch (error) {
if (error.name === 'TimeoutError') {
res.status(504).json({ error: 'API upstream bị quá thời gian' });
return;
}
next(error);
}
});
Không có pattern này, một API upstream chậm có thể chặn toàn bộ route của bạn cho đến khi client tự bỏ cuộc.
Logic retry và tái sử dụng kết nối: Nâng Node.js Fetch API lên chuẩn production
Fetch gốc không có retry tích hợp. Một trục trặc mạng nhỏ hay lỗi 503 nhất thời là request thất bại luôn. Với đa số thao tác đọc trong production, điều đó là không chấp nhận được.
Một wrapper retry có thể ghép nối với exponential backoff
Đoạn này cố ý ngắn — chỉ khoảng 10 dòng logic thực:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
export async function fetchWithRetry(url, options = {}, retries = 2) {
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok || ![408, 429, 500, 502, 503, 504].includes(response.status)) {
return response;
}
if (attempt >= retries) return response;
} catch (error) {
if (attempt >= retries) throw error;
}
await wait(250 * 2 ** attempt); // 250ms, 500ms, 1000ms...
}
}
Khi nào nên retry, và khi nào không nên
- Nên retry: Các request GET và HEAD mang tính idempotent, trạng thái tạm thời (408, 429, 500, 502, 503, 504), lỗi mạng chập chờn.
- Không nên retry: POST không idempotent dùng để tạo bản ghi, trừ tiền, hoặc kích hoạt hiệu ứng phụ — trừ khi bạn dùng idempotency key.
- Tôn trọng Retry-After: Với 429 (rate limit) và 503 (service unavailable), hãy kiểm tra header
Retry-Aftertrước khi backoff.
Nếu bạn không muốn tự viết retry logic, Ky là một wrapper fetch nhẹ, bổ sung retry, timeout, hooks và HTTPError sẵn có — không có dependency nào.
Tái sử dụng kết nối với Agent và Pool của Undici
Với các vòng lặp lưu lượng cao — scrape hàng trăm trang, gọi API theo lô, polling một service — việc tái sử dụng TCP connection sẽ tiết kiệm đáng kể thời gian. Mỗi kết nối mới đồng nghĩa với một lần DNS lookup mới, bắt tay TCP mới, và (với HTTPS) thương lượng TLS mới.
Vì fetch của Node được hỗ trợ bởi Undici, bạn có thể truyền một dispatcher tùy biến:
import { Agent } from 'undici';
const agent = new Agent({
keepAliveTimeout: 10_000,
keepAliveMaxTimeout: 60_000,
});
const response = await fetch('https://api.example.com/data', {
dispatcher: agent,
});
Muốn kiểm soát nhiều hơn nữa với một origin cụ thể:
import { Pool } from 'undici';
const pool = new Pool('https://api.example.com', { connections: 10 });
const response = await fetch('https://api.example.com/data', {
dispatcher: pool,
});
// Khi xong:
await pool.close();
Các benchmark trong README của Undici cho thấy việc tái sử dụng kết nối và pooling có thể cải thiện thông lượng rất mạnh — undici - dispatch đạt khoảng 22.234 req/sec so với undici - fetch khoảng 5.904 req/sec trong benchmark cục bộ của họ. Con số thực tế sẽ khác nhau, nhưng xu hướng thì rõ ràng: nếu bạn gửi rất nhiều request tới cùng một origin, pooling là quan trọng.
Thêm một điều nữa: hãy luôn tiêu thụ hoặc huỷ response body. Body không được đọc có thể gây rò rỉ tài nguyên trong nội bộ HTTP của Node.
Streaming response với Node.js Fetch API
Tải file lớn, nguồn JSON theo từng chunk, server-sent events, output của LLM — đây là những trường hợp mà chờ toàn bộ response rồi mới xử lý sẽ tốn thời gian và bộ nhớ. Streaming cho phép bạn xử lý dữ liệu ngay khi nó tới.

Node 18+ có ReadableStream tương thích với browser. Đây là cách stream một response JSON phân tách theo dòng và xử lý từng dòng ngay khi nó tới:
const response = await fetch('https://example.com/large-file.ndjson');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
const item = JSON.parse(line);
console.log('Đã xử lý:', item.id);
}
}
}
Với streaming văn bản đơn giản hơn (ví dụ: đẩy output của LLM ra stdout):
const response = await fetch('https://example.com/stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value, { stream: true }));
}
Streaming là một mảng mà native fetch và Got đều làm rất tốt. Hỗ trợ streaming của Axios thì hạn chế hơn.
Khi fetch() chạm giới hạn: Scrape web có cấu trúc bằng API
Đến một lúc nào đó, fetch không còn là nút thắt nữa. Vấn đề thật sự trở thành: “Tôi có HTML rồi, giờ làm gì tiếp?”

Fetch là một HTTP client — nó lấy về bytes, text, JSON hoặc HTML. Nó không hiểu thế nào là thẻ sản phẩm, giá, đánh giá hay bảng liên hệ. Với web scraping có cấu trúc, stack thô điển hình sẽ như sau:
- Dùng
fetch()để tải HTML - Dùng Cheerio (hoặc tương tự) để chọn phần tử bằng CSS selector
- Tự viết logic pagination
- Render JavaScript khi trang được dựng ở client
- Xử lý proxy/anti-bot/CAPTCHA
- Bảo trì selector mỗi khi layout của site thay đổi
Đây là ví dụ fetch + Cheerio điển hình — khoảng 15 dòng để scrape tiêu đề sản phẩm:
import * as cheerio from 'cheerio';
const response = await fetch('https://example-store.com/products');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
const $ = cheerio.load(html);
const products = $('.product-card')
.map((_, el) => ({
name: $(el).find('.product-title').text().trim(),
price: $(el).find('.price').text().trim(),
url: new URL($(el).find('a').attr('href'), response.url).href,
}))
.get();
console.log(products);
Cách này hoạt động tốt với các trang ổn định và HTML có cấu trúc dự đoán được. Nhưng nó nhanh chóng trở nên mong manh — nội dung render bằng JavaScript, class name thay đổi, biện pháp chống bot và pagination đều làm tăng độ phức tạp.
Open API của Thunderbit: Từ HTML thô tới dữ liệu có cấu trúc chỉ với một lần gọi
Đây là lúc một loại công cụ khác trở nên hữu ích. Tại Thunderbit, chúng tôi đã xây một lớp API xử lý những phần rắc rối — render JavaScript, chống bot, thay đổi layout — để bạn tập trung vào dữ liệu thật sự mình cần.
Distill API (POST /distill): Chuyển bất kỳ URL nào thành Markdown sạch. Hữu ích cho việc nạp vào LLM, xây knowledge base, hoặc phân tích nội dung — không cần HTML parser.
Extract API (POST /extract): Định nghĩa một JSON Schema mô tả dữ liệu có cấu trúc bạn muốn (tên sản phẩm, giá, đánh giá), và AI sẽ trích xuất nó. Không cần CSS selector, không vỡ khi layout thay đổi.
Đây là cùng một tác vụ scrape sản phẩm nhưng dùng Extract API của Thunderbit — gọi bằng fetch gốc:
const response = await fetch('https://openapi.thunderbit.com/openapi/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.THUNDERBIT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example-store.com/products',
renderMode: 'basic',
schema: {
type: 'object',
properties: {
products: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Tên sản phẩm' },
price: { type: 'string', description: 'Giá sản phẩm hiển thị' },
rating: { type: 'number', description: 'Đánh giá trung bình của khách hàng' },
},
required: ['name', 'price'],
},
},
},
required: ['products'],
},
}),
});
if (!response.ok) throw new Error(`Thunderbit API: ${response.status}`);
const result = await response.json();
console.log(result.data);
So sánh: khoảng 15 dòng fetch + Cheerio (kèm selector dễ gãy) so với một lời gọi API duy nhất trả về JSON sạch. Với batch job, Thunderbit hỗ trợ tối đa 50 URL cho mỗi lần batch extract và tối đa 100 URL cho mỗi lần batch distill.
Thunderbit không thay thế fetch — fetch là lớp vận chuyển. Thunderbit là lớp trích xuất mà bạn tìm đến khi việc parse HTML thô mới là vấn đề thật sự. Nếu bạn tò mò về giá, gói miễn phí cho bạn 600 API units để thử nghiệm, và các gói trả phí bắt đầu từ 6 USD/tháng. Bạn cũng có thể xem Thunderbit Chrome Extension để trích xuất không cần code trực tiếp trên trình duyệt.
Để tìm hiểu thêm về các cách tiếp cận scraping có cấu trúc, các hướng dẫn của chúng tôi về công cụ trích xuất dữ liệu tốt nhất, cách tạo web scraper, và thu thập dữ liệu từ website sang Excel sẽ đi sâu vào từng quy trình cụ thể.
Tài liệu tham khảo nhanh: Cheat sheet Node.js Fetch API
Phần này nên được đánh dấu bookmark. Hãy quay lại khi bạn cần một pattern để copy-paste.
| Pattern | Đoạn mã |
|---|---|
| GET cơ bản | const res = await fetch(url); const data = await res.json(); |
| POST cơ bản | await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); |
| Kiểm tra lỗi HTTP | if (!res.ok) throw new Error(\\HTTP ${res.status}\); |
| Timeout (đơn giản) | await fetch(url, { signal: AbortSignal.timeout(5000) }); |
| Huỷ thủ công | const c = new AbortController(); setTimeout(() => c.abort(), 5000); await fetch(url, { signal: c.signal }); |
| Trạng thái nên retry | Retry 408, 429, 500, 502, 503, 504. Đừng retry POST một cách mù quáng. |
| Wrapper JSON | Dùng fetchJSON() để kiểm tra ok, parse content type, ném HTTPError. |
| Connection pool | import { Pool } from 'undici'; const pool = new Pool(origin, { connections: 10 }); fetch(url, { dispatcher: pool }); |
| Stream theo chunk | const reader = res.body.getReader(); lặp qua await reader.read() |
| Trích xuất có cấu trúc | Dùng Thunderbit Extract API khi mục tiêu là các trường dữ liệu từ trang web, không phải HTML thô. |
Kết luận và những điểm chính cần nhớ
Fetch gốc trong Node.js đã sẵn sàng cho production vào năm 2026 — dự án mới không cần node-fetch, cũng không cần phụ thuộc Axios mặc định. Nhưng chỉ riêng fetch() thô thì chưa phải là một chiến lược HTTP hoàn chỉnh cho production.
Năm điều mà phần lớn tutorial bỏ qua — và bài này đã đề cập:
- Bẫy lỗi:
fetch()không throw với 4xx/5xx. Luôn kiểm traresponse.okhoặc dùng wrapper nhưfetchJSON(). - Timeout: Dùng
AbortSignal.timeout()cho trường hợp đơn giản.AbortSignal.timeout()némTimeoutError;controller.abort()thủ công némAbortError. - Logic retry: Không có sẵn. Thêm exponential backoff cho request idempotent và lỗi tạm thời. Hoặc dùng Ky để có retry kiểu fetch ngay từ đầu.
- Tái sử dụng kết nối: Với vòng lặp có lưu lượng cao, dùng
AgenthoặcPoolcủa Undici qua optiondispatcher. - Trích xuất có cấu trúc: Khi bạn cần dữ liệu từ trang web chứ không chỉ HTML thô, hãy cân nhắc một API trích xuất như Thunderbit thay vì phải bảo trì selector CSS dễ gãy.
Câu quyết định trong một câu: dùng fetch gốc cho phần lớn dự án, Axios cho interceptor, Got cho retry tích hợp và HTTP/2, Ky cho fetch với mặc định tốt hơn, và API của Thunderbit khi script scrape dựa trên fetch của bạn trở nên quá phức tạp để bảo trì.
Hãy thử Thunderbit để trích xuất dữ liệu có cấu trúc
Hãy thử các pattern trong hướng dẫn này. Và nếu bạn muốn xem Thunderbit xử lý trích xuất có cấu trúc như thế nào, gói miễn phí là một nơi bắt đầu rất tốt — hoặc xem video hướng dẫn trên kênh YouTube Thunderbit.
Hãy thử Thunderbit cho web scraping bằng AI Get Started Free
Câu hỏi thường gặp
1. Fetch có tích hợp sẵn trong Node.js hay tôi cần cài thêm?
Fetch được tích hợp sẵn trong Node.js 18 trở lên — không cần cài đặt. Nó trở nên ổn định ở Node 21 và được hỗ trợ đầy đủ trong Node 22 LTS và Node 24 LTS. Với các phiên bản Node cũ hơn, bạn có thể dùng package npm node-fetch, nhưng dự án mới nên nhắm tới một bản LTS còn được bảo trì.
2. Fetch có ném lỗi khi nhận phản hồi 404 hoặc 500 không?
Không. Fetch chỉ reject promise khi gặp lỗi ở mức mạng (lỗi DNS, mất kết nối, request bị huỷ). Các phản hồi HTTP như 404, 403 và 500 vẫn resolve bình thường với response.ok === false. Bạn phải kiểm tra response.ok hoặc response.status một cách rõ ràng — hoặc dùng wrapper như hàm fetchJSON() trong hướng dẫn này.
3. Làm sao thêm timeout cho fetch trong Node.js?
Cách đơn giản nhất là AbortSignal.timeout(ms), có trong Node 18.11+: await fetch(url, { signal: AbortSignal.timeout(5000) }). Cách này sẽ ném TimeoutError nếu request vượt quá 5 giây. Nếu muốn kiểm soát nhiều hơn, hãy tạo AbortController thủ công và gọi controller.abort() từ một setTimeout. Bắt AbortError cho pattern thủ công và TimeoutError cho AbortSignal.timeout().
4. Tôi có thể dùng fetch cho web scraping trong Node.js không?
Có, nhưng fetch chỉ trả về HTML thô. Bạn sẽ cần một parser như Cheerio để trích xuất phần tử cụ thể, cùng logic riêng cho pagination, trang render bằng JavaScript và biện pháp chống bot. Với việc trích xuất dữ liệu có cấu trúc ở quy mô lớn — khi bạn muốn JSON sạch với tên sản phẩm, giá hoặc thông tin liên hệ — hãy cân nhắc Extract API của Thunderbit, dùng AI để trả về dữ liệu có cấu trúc mà không cần CSS selector hay code phụ thuộc layout.
5. Năm 2026 tôi có nên chuyển từ Axios sang fetch gốc không?
Với dự án mới trên Node 22+, fetch gốc là một mặc định rất mạnh. Nó không cần dependency, dựa trên Promise và chia sẻ cùng API với fetch trong browser. Hãy giữ Axios nếu bạn phụ thuộc vào interceptor request/response, mặc định reject lỗi HTTP, hoặc cần tương thích ngược với phiên bản Node cũ. Cả hai đều là lựa chọn hợp lệ — quyết định phụ thuộc vào những tính năng mà dự án của bạn thực sự sử dụng.
Tìm hiểu thêm


