Streams API cho Web App: khi nào streaming giúp thật và khi nào chỉ làm code phức tạp hơn?
Có một cách nói về Streams API rất dễ tạo hiểu nhầm: “thay Fetch truyền thống bằng streaming là web app sẽ hết lag.”
Không đơn giản như vậy.
fetch() vốn đã có thể trả về response body dưới dạng ReadableStream. Vấn đề không phải “Fetch hay Streams”, mà là ứng dụng có đợi toàn bộ dữ liệu rồi mới xử lý hay có thể xử lý dần theo từng chunk.
Streams API đáng dùng khi dữ liệu đủ lớn, đến liên tục hoặc có thể được biến đổi theo pipeline. Với payload nhỏ và logic đơn giản, streaming đôi khi chỉ làm code khó đọc hơn.
Streams API giải quyết bài toán gì?
WHATWG Streams Standard mô tả streams như các API để tạo, kết hợp và tiêu thụ dữ liệu theo cách incremental, không cần đọc toàn bộ dữ liệu vào bộ nhớ trước.
Ba primitive quan trọng nhất là:
ReadableStream: nguồn dữ liệu có thể đọc dần;WritableStream: đích nhận dữ liệu;TransformStream: bước biến đổi nằm giữa nguồn và đích.
Các stream có thể nối với nhau bằng pipeThrough() và pipeTo() thành một pipeline.
Ví dụ khái niệm:
HTTP response
↓
ReadableStream
↓
TextDecoderStream
↓
TransformStream
↓
WritableStream / UI / storage
Điểm giá trị không phải “stream trông hiện đại hơn”, mà là mỗi tầng có thể xử lý dữ liệu khi nó đến.
1. Lợi ích thật: progressive processing
Giả sử server trả một báo cáo lớn.
Cách đơn giản nhất thường là:
const response = await fetch('/report.json');
const data = await response.json();
render(data);
Đây là lựa chọn tốt nếu payload vừa phải và UI chỉ có ý nghĩa khi toàn bộ JSON đã hoàn tất.
Nhưng nếu endpoint trả dữ liệu theo định dạng có thể xử lý từng record — ví dụ NDJSON — ứng dụng có thể bắt đầu parse và hiển thị trước khi toàn bộ response kết thúc.
Khi đó perceived responsiveness có thể tốt hơn vì người dùng thấy tiến trình hoặc kết quả sớm hơn.
Điều cần phân biệt: streaming không tự động làm Time To First Byte thấp hơn. TTFB phụ thuộc vào thời gian server bắt đầu gửi response và network. Streaming tác động nhiều hơn tới thời điểm ứng dụng có thể bắt đầu xử lý nội dung sau byte đầu tiên.
2. Lợi ích thứ hai: không cần giữ toàn bộ payload trong bộ nhớ
Nếu ứng dụng tải một file lớn rồi nối tất cả chunk vào một string khổng lồ, chúng ta gần như đã phá mất một phần lợi ích của streaming.
Ví dụ không tối ưu:
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
}
Đoạn code này vẫn đọc theo chunk nhưng cuối cùng giữ toàn bộ dữ liệu trong result.
Streaming có ý nghĩa hơn khi mỗi phần dữ liệu có thể được xử lý rồi giải phóng hoặc chuyển tiếp:
const response = await fetch('/events.ndjson');
const stream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitLinesTransform())
.pipeThrough(parseJsonTransform());
for await (const record of stream) {
appendRow(record);
}
Ở đây mục tiêu là giữ working set nhỏ hơn thay vì tích toàn bộ response trong RAM.
3. Backpressure là gì?
Backpressure là một phần quan trọng của Streams Standard.
Trong một pipe chain, nếu bước phía sau chưa thể nhận thêm chunk, tín hiệu về tốc độ có thể được truyền ngược qua pipeline để bước phía trước không tiếp tục đẩy dữ liệu nhanh hơn khả năng xử lý của downstream.
Nói đơn giản:
Nguồn nhanh → Transform chậm → Đích chậm
↑
backpressure
Điều này giúp queue không tăng vô hạn trong pipeline.
Tuy nhiên, không nên biến backpressure thành câu “browser sẽ ra lệnh cho server giảm tốc độ” trong mọi trường hợp. Backpressure của Web Streams điều phối các stream và queue trong pipeline; cách nó tương tác với tầng network/HTTP cụ thể phụ thuộc vào implementation và protocol.
4. Chunk không đồng nghĩa với một record hoàn chỉnh
Đây là lỗi rất dễ gặp khi mới dùng stream.
Network có thể chia bytes ở bất kỳ ranh giới nào. Một chunk có thể chứa:
- nửa một dòng text;
- nhiều dòng cùng lúc;
- nửa một ký tự đa byte nếu decode không đúng;
- một phần của JSON object.
Vì vậy code kiểu này thường sai về mặt giả định:
const { value } = await reader.read();
const item = JSON.parse(new TextDecoder().decode(value));
Không có bảo đảm mỗi value là một JSON object hoàn chỉnh.
Nếu cần stream record, nên chọn wire format có ranh giới rõ, ví dụ:
- NDJSON / JSON Lines;
- Server-Sent Events;
- framed binary protocol;
- một format ứng dụng tự định nghĩa có delimiter/length.
Sau đó transform stream chịu trách nhiệm buffer phần record chưa hoàn chỉnh giữa các chunk.
5. Ví dụ: tách NDJSON theo dòng
Một transform đơn giản có thể giữ phần text dư giữa các chunk:
function splitLinesTransform() {
let buffer = '';
return new TransformStream({
transform(chunk, controller) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) controller.enqueue(line);
}
},
flush(controller) {
if (buffer.trim()) controller.enqueue(buffer);
}
});
}
Sau đó parse từng dòng:
function parseJsonTransform() {
return new TransformStream({
transform(line, controller) {
controller.enqueue(JSON.parse(line));
}
});
}
Pipeline trở thành:
const records = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitLinesTransform())
.pipeThrough(parseJsonTransform());
Điểm hay ở đây là mỗi transform làm đúng một nhiệm vụ và có thể tái sử dụng.
6. Khi nào Streams API đáng dùng?
Tôi sẽ cân nhắc streams khi gặp một trong các tình huống sau.
Dữ liệu lớn
Ví dụ export/import, log, file, dataset hoặc response đủ lớn khiến việc giữ toàn bộ payload trong memory trở thành vấn đề thực tế.
Dữ liệu đến liên tục
Ví dụ AI response dạng streaming, event feed hoặc long-running process trả tiến trình theo thời gian.
Cần hiển thị kết quả dần
Nếu người dùng có thể bắt đầu đọc hoặc thao tác với phần đầu mà không cần đợi phần cuối.
Cần pipeline transform
Decode → parse → filter → transform → write là mô hình tự nhiên của stream.
7. Khi nào không nên dùng?
Payload nhỏ
Nếu API trả vài KB hoặc vài chục KB và response.json() hoạt động tốt, stream có thể không tạo giá trị đủ để bù complexity.
UI cần toàn bộ dữ liệu trước khi tính toán
Một số thuật toán sort, aggregate hoặc validate cần toàn bộ dataset. Khi đó vẫn có thể stream từ network nhưng cuối pipeline vẫn phải tích dữ liệu lại.
Backend không có format phù hợp
Một JSON array khổng lồ không tự nhiên trở thành record stream chỉ vì frontend dùng ReadableStream. Nếu muốn progressive parsing, wire format cần được thiết kế tương ứng.
Team chưa có cách test lỗi giữa stream
Streaming thêm các trạng thái mới: cancel, abort, partial data, malformed record, reconnect và cleanup. Nếu những đường lỗi này không được test, UX có thể tệ hơn cách tải toàn bộ đơn giản.
8. Performance: đo trước khi đổi kiến trúc
Trước khi chuyển một API sang streaming, tôi sẽ đo ít nhất:
- payload size;
- thời gian server bắt đầu response;
- thời gian tới nội dung hữu ích đầu tiên;
- peak memory;
- main-thread work khi parse/render;
- số row/item thực sự cần render;
- behavior trên mobile yếu;
- tỷ lệ request bị cancel hoặc lỗi giữa chừng.
Nếu bottleneck thật là DOM render 20.000 rows cùng lúc, streaming network không giải quyết trực tiếp. Có thể cần virtualization hoặc pagination.
Nếu bottleneck là server mất 8 giây mới bắt đầu response, streaming frontend cũng không sửa được TTFB.
Đây là lý do phải chẩn đoán bottleneck trước khi chọn Streams API.
9. Checklist triển khai an toàn
- Kiểm tra
response.okvàresponse.bodytrước khi đọc. - Dùng
AbortControllerkhi request cần hủy. - Đảm bảo decoder xử lý đúng ranh giới multibyte.
- Không giả định một chunk = một record.
- Có giới hạn buffer cho transform tự viết.
- Xử lý malformed record rõ ràng.
- Test slow consumer để quan sát backpressure.
- Test cancel giữa chừng và cleanup resource.
- Đo memory trước/sau thay vì chỉ nhìn cảm giác “mượt”.
- Giữ fallback đơn giản nếu use case không cần streaming.
Kết luận
Streams API không phải “tuyệt chiêu chống lag”. Nó là một abstraction rất hữu ích khi dữ liệu có thể được xử lý theo dòng chảy thay vì theo khối hoàn chỉnh.
Lợi ích lớn nhất là:
- bắt đầu xử lý sớm hơn;
- giảm nhu cầu giữ toàn bộ payload trong memory;
- tổ chức transform thành pipeline;
- có backpressure giữa các bước của stream.
Nhưng nếu bottleneck nằm ở server, DOM rendering hoặc thuật toán cần toàn bộ dataset, Streams API không tự giải quyết vấn đề.
Chọn streaming vì mô hình dữ liệu và bottleneck cần nó — không phải vì API này nghe “performance” hơn.