Skip to content
DevRecall
← Roadmap
33

Scenarios: File Upload, Download & Streaming

drafted

Mock interview production scenarios — upload 100MB đồng thời, presigned URL/POST, multipart & resumable, streaming qua backend với backpressure, validate file, download/Range/zip, SSE/NDJSON/LLM streaming, Socket.IO realtime ở scale, media pipeline và chi phí S3.

0/58 rated · mastery 0%

Bài học

6 bài · ~95 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~17 phút· 11 câuverify

    Upload file lớn: đưa bytes ra khỏi API

    Playbook cho 100 upload 100 MB đồng thời: tính memory, presigned PUT vs POST, state machine + confirm, giới hạn từng tầng, burst deadline và thiết kế file service multi-tenant. Có số đo thật: Multer memoryStorage vs busboy, presigned PUT/POST trên MinIO.

  2. 02~15 phút· 7 câuverify

    Multipart, resumable upload và integrity

    Part size, CORS ExposeHeaders ETag, resume bằng ListParts, progress/cancel/retry, checksum và ETag multipart, S3 vs tus vs tự viết, incomplete MPU trong bill. Có lab thật trên MinIO: resume sau crash, ETag = md5 của các md5 + -N, EntityTooSmall.

  3. 03~15 phút· 8 câuverify

    Stream upload qua backend với backpressure

    Khi bytes bắt buộc đi qua Node: busboy → Transform → lib-storage, memory thật, limit/truncated, abort và leak, Transform async mất backpressure, thiết kế 200 upload 1 GB trên pod 2 GB.

  4. 04~16 phút· 11 câuverify

    Validate, quét virus và pipeline xử lý file

    Magic bytes, re-encode, SVG XSS, path traversal, zip bomb/pixel bomb, EXIF, header khi serve, quarantine + scan fail-closed, S3 event at-least-once, job transcode idempotent, threat model.

  5. 05~18 phút· 12 câuverify

    Download, Range request, ZIP và export lớn

    Presigned GET/CloudFront signed URL/cookie, proxy stream đúng (pipeline, Web Streams), Range/206/416, ZIP on-the-fly vs async, export NDJSON/CSV qua COPY, HLS và chi phí S3/CloudFront.

  6. 06~15 phút· 9 câuverify

    SSE, LLM streaming và Socket.IO ở scale

    SSE vs WebSocket, buffering ở proxy/compression, huỷ upstream LLM, Last-Event-ID replay, slow consumer và conflation, seq + snapshot khi reconnect, Redis adapter fan-out, half-open connection.

Tóm tắt & cheat sheet của track

TL;DR

  • Quy tắc số 1: bytes không đi qua API server nếu không bắt buộc. Client upload/download thẳng với object storage (S3) bằng presigned URL/POST hoặc CloudFront signed URL/cookie; API chỉ ký, kiểm quyền, lưu metadata.
  • Khi bytes bắt buộc đi qua backend: stream end-to-end (busboy → @aws-sdk/lib-storage Upload), giới hạn size trong lúc stream, abort khi client ngắt, và hiểu memory thật = partSize × queueSize × số upload đồng thời, không phải "64KB".
  • Upload không "xong" khi client nói xong: dùng state machine trong DB (pending → uploaded → scanned → ready | rejected), xác nhận bằng HeadObject/S3 event, xử lý nặng (scan, thumbnail, transcode) qua queue + worker idempotent.
  • Validate như kẻ tấn công: không tin Content-Type/extension, sniff magic bytes, re-encode ảnh, giới hạn giải nén, key do server sinh, serve từ domain riêng + Content-Disposition + nosniff.
  • Streaming response (NDJSON, SSE, LLM tokens, Socket.IO) chết vì buffering ở giữa (proxy, compression), slow consumer và reconnect không resync. Luôn có heartbeat, sequence number, và chiến lược drop/coalesce.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Presigned PUTURL ký SigV4 cho phép PUT đúng 1 key, trong TTLgetSignedUrl(s3, new PutObjectCommand({Bucket, Key}), { expiresIn: 300 })
Presigned POSTForm POST + policy có điều kiện (size, content-type prefix, key prefix)["content-length-range", 1, 104857600]
Multipart uploadChia object thành part (≥5 MiB trừ part cuối, tối đa 10,000 part), upload song song, Complete bằng danh sách ETag100 MB → 10 part × 10 MB
Resumable uploadLưu uploadId + part đã xong, ListParts để tiếp tụcBrowser đóng tab, mở lại upload tiếp part 7
BackpressureConsumer chậm báo producer dừng (write() trả false → chờ 'drain')S3 chậm → busboy ngừng đọc socket → TCP window co lại
highWaterMarkNgưỡng buffer nội bộ của stream trước khi báo "đầy"fs.createReadStream mặc định 64 KiB
pipeline()Nối stream + propagate error + destroy tất cả khi lỗi/abortawait pipeline(s3Body, gzip, res)
Range requestClient xin một đoạn byte → server trả 206 Partial ContentRange: bytes=0-1048575
NDJSONMỗi dòng một JSON object, parse được từng dòng{"id":1}\n{"id":2}\n
SSEtext/event-stream một chiều server→client, tự reconnect với Last-Event-IDid: 42\ndata: {...}\n\n
Conflation / coalesceGộp nhiều update của cùng key, chỉ gửi bản mới nhất cho client chậmGiá AAPL đổi 50 lần/s, gửi 4 lần/s
Magic bytesChữ ký đầu file xác định format thậtPNG bắt đầu 89 50 4E 47

Cơ chế hoạt động

Direct-to-S3 upload với confirm + async processing

Upload bắt buộc qua backend — streaming với backpressure

Realtime stream (Socket.IO) với resync

Khung trả lời

Dùng cho mọi câu "file lớn / streaming trong production":

  1. Hỏi lại con số: kích thước file (p50/p99/max), số upload/download đồng thời, client (browser/mobile/server), SLA, data nhạy cảm không, có yêu cầu compliance bắt bytes đi qua mình không.
  2. Tính memory/bandwidth trên giấy: concurrent × buffered bytes. 100 × 100 MB buffered = 10 GB; 100 × (5 MB × 4) lib-storage = 2 GB; 100 × 64 KB stream thuần = 6.4 MB.
  3. Đưa bytes ra khỏi API: presigned POST/PUT, multipart presigned từng part, CloudFront cho download.
  4. Nếu phải qua backend: stream end-to-end, size limit lúc stream, abort + cleanup, giới hạn ở proxy/LB, admission control (số upload đồng thời/pod → 429/503).
  5. Trạng thái & bất đồng bộ: state machine DB, event → queue → worker idempotent, không serve trước khi scan xong.
  6. Bảo mật: magic bytes, re-encode, zip/decompression limits, key server-generated, domain riêng, nosniff, Content-Disposition.
  7. Vận hành & chi phí: lifecycle rules, incomplete multipart, request cost, egress, metrics (bytes in-flight, upload fail rate, p99 duration), alert.

Trade-offs

Cách uploadMemory APIEnforce sizeValidate trước khi lưuResumablePhức tạpHợp khi
Multer memoryStorageToàn bộ file × Nlimits.fileSizeCóKhôngThấpFile nhỏ (avatar < 5 MB), traffic thấp
Multer diskStorageThấp, nhưng tốn disk tạmCóCó (đọc lại từ disk)KhôngThấpCần CLI xử lý file local
busboy → lib-storagepartSize × queueSize mỗi uploadCó, lúc streamCó (inline)Không (trừ tự làm)Trung bìnhCompliance bắt đi qua backend
Presigned PUT~0Không (chỉ ký header)Sau khi uploadKhôngThấpFile vừa, client tin cậy tương đối
Presigned POST~0Có (content-length-range)Sau khi uploadKhôngThấpBrowser form upload, cần chặn size
Multipart presigned từng part~0Qua số part × part size + check khi CompleteSau khi uploadCóCaoFile > 100 MB, mạng yếu
tus protocol (tusd/Uppy)Tuỳ serverCó (Upload-Length, max-size)Có hookCó, chuẩn hoáTrung bìnhCần resumable đa nền tảng, không lock vào S3 API
Streaming responseHướngReconnectQua proxyHợp khi
Chunked + NDJSONServer → client, 1 requestTự làm (cursor)Dễ bị bufferExport lớn, batch API
SSEServer → clientBuilt-in Last-Event-IDCần tắt buffering, HTTP/1.1 giới hạn ~6 connection/hostLLM tokens, notification, dashboard
WebSocket / Socket.IOHai chiềuTự làm (Socket.IO có connection state recovery)Cần Upgrade, sticky sessionTrading, chat, collaborative

Pitfalls

  • Tin rằng "dùng Multer là stream": memoryStorage giữ nguyên file trong file.buffer; diskStorage ghi ra disk. file.stream không có trong engine built-in.
  • Presigned PUT không giới hạn size được — cần presigned POST hoặc kiểm tra sau upload + xoá.
  • PQueue({ concurrency: 5 }) trong handler chỉ giới hạn việc xử lý; 500 request vẫn đang mở socket và nhận bytes. Admission control phải ở LB/proxy hoặc trước khi đọc body.
  • Dùng .pipe() cho download S3 → client abort thì S3 socket không bị destroy → leak connection trong pool của SDK (mặc định maxSockets 50 — verify).
  • Không có AbortIncompleteMultipartUpload → part rác tính tiền storage vô thời hạn và không hiện trong ListObjects.
  • Serve file user upload từ cùng domain app với Content-Type do client gửi → SVG/HTML chạy script = stored XSS.
  • Zip-on-the-fly: status 200 đã gửi, lỗi giữa chừng → file zip hỏng mà client không biết.
  • SSE/NDJSON bị compression() hoặc nginx proxy_buffering on gom lại → client nhận một cục ở cuối.
  • Socket.IO server emit cho client chậm → buffer của socket phình vô hạn → OOM ở giờ mở cửa thị trường.

Cheat sheet

  • S3: single PUT ≤ 5 GiB; multipart part 5 MiB–5 GiB, ≤ 10,000 part; object tối đa 5 TiB (AWS có thông báo nâng lên 50 TB cuối 2025 — verify). Presigned URL tối đa 7 ngày (SigV4), nhưng hết hạn sớm hơn nếu ký bằng temporary credentials.
  • Part size cho file lớn: ceil(size / 10000) làm tròn lên ≥ 5 MiB; 100 GB → ≥ ~10.5 MB/part.
  • @aws-sdk/lib-storage Upload: mặc định partSize 5 MiB, queueSize 4 → ~20 MB buffer mỗi upload (verify). Có upload.abort() và event httpUploadProgress.
  • Proxy/LB: nginx client_max_body_size mặc định 1m, proxy_request_buffering on mặc định; ALB idle timeout mặc định 60s; API Gateway payload 10 MB; Lambda sync invoke 6 MB request/response (verify).
  • Node streams: highWaterMark mặc định 64 KiB cho byte stream từ Node 22 (trước đó 16 KiB), 16 object cho object mode; fs.createReadStream 64 KiB (verify). Luôn await pipeline(...) từ node:stream/promises.
  • Validate: file-type đọc magic bytes; sharp re-encode + limitInputPixels; unzip giới hạn tổng bytes, số entry, ratio, và chặn ../.
  • Download: presigned GET TTL ngắn + ResponseContentDisposition; CloudFront signed cookies cho HLS (nhiều segment), signed URL cho 1 file.
  • Realtime: sticky session (khi có polling), Redis/Streams adapter, heartbeat, seq mỗi message, snapshot + delta khi gap lớn, conflate cho client chậm.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 58 / 58

import { createPresignedPost } from "@aws-sdk/s3-presigned-post";

const { url, fields } = await createPresignedPost(s3, {
  Bucket: "uploads-quarantine",
  Key: `u/${userId}/${crypto.randomUUID()}`,
  Conditions: [
    ["content-length-range", 1, 100 * 1024 * 1024],
    ["starts-with", "$Content-Type", "video/"],
  ],
  Fields: { "Content-Type": declaredType },
  Expires: 300, // seconds
});
// browser: FormData(fields..., file LAST) → POST url
{
  "Rules": [
    { "ID": "abort-mpu", "Status": "Enabled", "Filter": {},
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 3 } },
    { "ID": "expire-pending", "Status": "Enabled",
      "Filter": { "Tag": { "Key": "status", "Value": "pending" } },
      "Expiration": { "Days": 1 } }
  ]
}
CREATE TABLE upload (
  id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  owner_id uuid NOT NULL,
  s3_key text NOT NULL UNIQUE,
  declared_size bigint NOT NULL,
  declared_type text NOT NULL,
  status text NOT NULL CHECK (status IN ('pending','uploaded','scanning','ready','rejected','expired')),
  sha256 text,
  created_at timestamptz NOT NULL DEFAULT now()
);
UPDATE upload SET status = 'uploaded' WHERE id = $1 AND owner_id = $2 AND status = 'pending';
import AWS from "aws-sdk";
const s3 = new AWS.S3();

export async function getUploadUrl(fileName: string) {
  return s3.getSignedUrlPromise("putObject", {
    Bucket: "uploads",
    Key: `videos/${fileName}`,
    ContentType: "video/mp4",
    Expires: 3600,
  });
}
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "POST", "GET"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]
@Post("upload")
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 150 * 1024 * 1024 } }))
upload(@UploadedFile() file: Express.Multer.File) {
  return s3.upload({ Bucket, Key: `v/${file.originalname}`, Body: file.stream }).promise();
}
import busboy from "busboy";
import { Upload } from "@aws-sdk/lib-storage";
import { PassThrough } from "node:stream";

app.post("/upload", (req, res) => {
  const bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: 150 * 1024 * 1024 } });
  bb.on("file", (_name, file, info) => {
    const body = new PassThrough();
    const upload = new Upload({
      client: s3,
      params: { Bucket, Key: `q/${crypto.randomUUID()}`, Body: body, ContentType: "application/octet-stream" },
      partSize: 8 * 1024 * 1024,
      queueSize: 2,
    });
    file.on("limit", () => { upload.abort(); res.status(413).end(); });
    req.on("close", () => { if (!res.writableEnded) upload.abort(); });
    file.pipe(body);
    upload.done().then(() => res.status(201).json({ ok: true })).catch(() => { if (!res.headersSent) res.status(500).end(); });
  });
  req.pipe(bb);
});
const queue = new PQueue({ concurrency: 5 });
app.post("/upload", upload.single("file"), (req, res) => {
  queue.add(() => uploadToS3(req.file!.buffer)).then(() => res.sendStatus(201));
});
import { fileTypeFromBuffer } from "file-type"; // ESM-only

const head = await readFirstBytes(s3Stream, 4100);
const ft = await fileTypeFromBuffer(head);
if (!ft || !["image/png", "image/jpeg"].includes(ft.mime)) throw new RejectedUpload("type");
import path from "node:path";
import fs from "node:fs";

bb.on("file", (_field, file, info) => {
  const target = path.join("/data/uploads", req.user.tenantId, info.filename);
  file.pipe(fs.createWriteStream(target));
});
app.get("/exports/:id/download", async (req, res) => {
  const obj = await s3.send(new GetObjectCommand({ Bucket, Key: keyFor(req.params.id) }));
  const bytes = await obj.Body!.transformToByteArray();
  res.setHeader("Content-Type", "application/zip");
  res.send(Buffer.from(bytes));
});
app.get("/orders/export", (req, res) => {
  const stream = db.streamOrders();
  res.write("[");
  stream.on("data", (row) => {
    res.write(JSON.stringify(row));
  });
  res.end("]");
});
retry: 3000
id: 1842
event: order-updated
data: {"orderId":"o_1","status":"shipped"}

: ping
import { Transform } from "node:stream";
import { createHash } from "node:crypto";

export function inspect(maxBytes: number) {
  const hash = createHash("sha256");
  let seen = 0; let head = Buffer.alloc(0); let checked = false;
  const t = new Transform({
    transform(chunk: Buffer, _enc, cb) {
      seen += chunk.length;
      if (seen > maxBytes) return cb(new Error("too_large"));
      if (!checked) {
        head = Buffer.concat([head, chunk]).subarray(0, 5);
        if (head.length >= 5) {
          checked = true;
          if (head.toString("latin1") !== "%PDF-") return cb(new Error("not_pdf"));
        }
      }
      hash.update(chunk);
      cb(null, chunk);
    },
    flush(cb) { t.emit("digest", hash.digest("hex"), seen); cb(); },
  });
  return t;
}
app.get("/folders/:id/zip", async (req, res) => {
  const keys = await listKeys(req.params.id); // 8,000 keys
  const archive = archiver("zip");
  archive.pipe(res);
  for (const key of keys) {
    const obj = await s3.send(new GetObjectCommand({ Bucket, Key: key }));
    archive.append(obj.Body as Readable, { name: key });
  }
  archive.finalize();
});
const bb = busboy({ headers: req.headers, limits: { fileSize: 50 * 1024 * 1024 } });
bb.on("file", (_n, file) => {
  const upload = new Upload({ client: s3, params: { Bucket, Key: key, Body: file } });
  upload.done().then(() => markReady(key)).then(() => res.sendStatus(201));
});
req.pipe(bb);
const toDb = new Transform({
  objectMode: true,
  transform(row, _enc, cb) {
    db.insert("items", row); // returns a Promise
    cb();
  },
});
await pipeline(fs.createReadStream(file), csvParser(), toDb, new Writable({ objectMode: true, write(_c, _e, cb) { cb(); } }));
import { Readable } from "node:stream";
export async function GET(req: Request) {
  const obj = await s3.send(new GetObjectCommand({ Bucket, Key }), { abortSignal: req.signal });
  return new Response(Readable.toWeb(obj.Body as Readable) as ReadableStream, {
    headers: { "Content-Type": obj.ContentType ?? "application/octet-stream", "Content-Length": String(obj.ContentLength) },
  });
}
app.get("/media/:id", async (req, res) => {
  const file = pathFor(req.params.id);
  const { size } = await fs.promises.stat(file);
  const [s, e] = (req.headers.range ?? "bytes=0-").replace("bytes=", "").split("-");
  const start = Number(s);
  const end = e ? Number(e) : size;
  res.writeHead(206, {
    "Content-Range": `bytes ${start}-${end}/${size}`,
    "Content-Length": end - start,
    "Content-Type": "video/mp4",
  });
  fs.createReadStream(file, { start, end }).pipe(res);
});
import { to as copyTo } from "pg-copy-streams";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";

if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error("bad tenant id"); // COPY has no bind params
const client = await replicaPool.connect();
try {
  await client.query("SET statement_timeout = '10min'");
  const src = client.query(copyTo(
    "COPY (SELECT id, booked_at, amount, currency FROM txn WHERE tenant_id = '" + tenantId + "') TO STDOUT WITH (FORMAT csv, HEADER)"
  ));
  res.setHeader("Content-Type", "text/csv");
  res.setHeader("Content-Encoding", "gzip");
  res.setHeader("Content-Disposition", 'attachment; filename="transactions.csv"');
  await pipeline(src, createGzip(), res);
} finally { client.release(); }
// NOTE: COPY does not accept bind parameters — tenantId must be a validated UUID (or use a view/temp function).