Ôn phỏng vấn Node.js

Ôn phỏng vấn

Câu hỏi phỏng vấn Node.js thường gặp (kèm gợi ý trả lời)

Câu hỏi phỏng vấn Node.js hay gặp: event loop, stream, xử lý lỗi, Express, NestJS, tác vụ nặng CPU, memory leak, mở rộng và bảo mật API. Gồm 12 câu do Drama Công Ty biên soạn: đọc ý chính rồi tự trả lời bằng ví dụ từ dự án của bạn.

  1. 01Node.js chạy đơn luồng thì làm sao xử lý được nhiều request cùng lúc? Node hợp với loại ứng dụng nào?Junior

    Gợi ý trả lời

    Code JavaScript của bạn chạy trên một thread, nhưng I/O (mạng, file, DB) là non-blocking: Node giao việc cho hệ điều hành hoặc thread pool của libuv, rồi tiếp tục xử lý request khác. Khi I/O xong, callback được đưa lại vào event loop.

    • Hợp với: API, BFF, real-time (WebSocket, chat), proxy, ứng dụng nhiều I/O và ít tính toán.
    • Không hợp nếu chỉ dùng thread chính cho tác vụ nặng CPU: mã hoá video, xử lý ảnh lớn, tính toán dài.
    // Chặn cả server: mọi request khác phải chờ
    app.get("/hash", (req, res) => {
      res.send(crypto.pbkdf2Sync(pw, salt, 1e6, 64, "sha512"));
    });
    

    Người phỏng vấn muốn nghe: "đơn luồng" chỉ đúng với phần code JS; Node vẫn dùng thread pool cho file system, DNS, một số hàm crypto và zlib.

  2. 02CommonJS và ES Modules khác nhau thế nào? Dự án mới nên dùng loại nào?Junior

    Gợi ý trả lời

    CommonJS dùng require/module.exports, nạp đồng bộ lúc chạy. ES Modules dùng import/export, là chuẩn của ngôn ngữ, được phân tích tĩnh nên hỗ trợ tree-shaking và top-level await.

    • Bật ESM bằng "type": "module" trong package.json hoặc đuôi .mjs.
    • Trong ESM không có sẵn __dirname; dùng import.meta.dirname (Node mới) hoặc tính từ import.meta.url.
    • ESM import được CommonJS. Chiều ngược lại trước đây phải dùng import() động; các bản Node gần đây đã cho require() module ESM đồng bộ (trừ module có top-level await).

    Dự án mới nên dùng ESM, vì cả hệ sinh thái đang chuyển dần sang đó.

    Hay sai: khi viết ESM thuần, quên ghi đuôi file trong đường dẫn import tương đối (./utils.js), dẫn đến lỗi không tìm thấy module.

  3. 03Middleware trong Express là gì? Viết error-handling middleware thế nào?Junior

    Gợi ý trả lời

    Middleware là hàm (req, res, next) chạy theo thứ tự đăng ký. Mỗi middleware có thể sửa req/res, trả response luôn, hoặc gọi next() để chuyển tiếp. Ví dụ: parse JSON, log, xác thực, giới hạn tần suất.

    Error-handling middleware có bốn tham số và đặt sau các route:

    app.use((err, req, res, next) => {
      logger.error(err);
      const status = err.status ?? 500;
      res.status(status).json({ message: status < 500 ? err.message : "Internal error" });
    });
    
    • Gọi next(err) để nhảy thẳng tới error handler.
    • Express 5 tự chuyển Promise bị reject trong handler async sang error handler; Express 4 thì phải tự try/catch rồi gọi next(err).

    Hay sai: quên gọi next() và cũng không trả response, khiến request treo đến khi timeout. Hoặc gửi response hai lần, gây lỗi "headers already sent".

  4. 04Xử lý lỗi trong code bất đồng bộ của Node thế nào? Chuyện gì xảy ra với Promise bị reject mà không ai bắt?Junior

    Gợi ý trả lời

    Với async/await, dùng try/catch như code đồng bộ. Với Promise, dùng .catch(). Lỗi ném trong callback kiểu cũ thì không bắt được bằng try/catch bên ngoài, nên callback dùng quy ước (err, result).

    • Từ Node 15, Promise bị reject mà không có handler (unhandled rejection) sẽ làm process thoát.
    • process.on("uncaughtException") chỉ nên dùng để log rồi thoát; trạng thái app lúc đó không còn đáng tin.
    async function getUser(id) {
      try {
        return await db.users.findById(id);
      } catch (err) {
        throw new Error("Load user failed", { cause: err });
      }
    }
    

    Hay sai: gọi hàm async mà không await hay .catch() ("fire and forget"). Lỗi xảy ra sau đó có thể làm sập process. Lỗi khác hay gặp: return promise thay vì return await promise bên trong try, khiến catch không bắt được lỗi.

  5. 05Event loop của Node gồm những phase nào? process.nextTick, Promise, setImmediate và setTimeout chạy theo thứ tự nào?

    Gợi ý trả lời

    Event loop của Node (libuv) đi qua các phase chính: timers (setTimeout, setInterval), poll (chờ và xử lý I/O), check (setImmediate), close callbacks.

    Giữa các callback, Node chạy hết hàng đợi process.nextTick trước, rồi đến microtask của Promise.

    setTimeout(() => console.log("timeout"));
    setImmediate(() => console.log("immediate"));
    Promise.resolve().then(() => console.log("promise"));
    process.nextTick(() => console.log("nextTick"));
    // nextTick, promise, rồi timeout/immediate
    
    • Thứ tự giữa setTimeout(0) và setImmediate ở module chính là không chắc chắn; nhưng nếu cả hai được đặt trong một callback I/O thì setImmediate luôn chạy trước.

    Hay sai: gọi process.nextTick đệ quy, khiến event loop không bao giờ sang được phase I/O.

  6. 06Stream trong Node là gì? Backpressure là gì? Làm sao xử lý file vài GB mà không hết RAM?

    Gợi ý trả lời

    Stream xử lý dữ liệu theo từng chunk thay vì đọc hết vào bộ nhớ. Có bốn loại: Readable, Writable, Duplex, Transform.

    Backpressure: khi bên ghi chậm hơn bên đọc, dữ liệu dồn lại trong bộ đệm. write() trả về false để báo nên dừng đọc cho đến khi có sự kiện drain.

    Dùng pipeline để tự xử lý backpressure, lỗi và dọn dẹp:

    import { pipeline } from "node:stream/promises";
    import { createGzip } from "node:zlib";
    
    await pipeline(
      fs.createReadStream("big.csv"),
      createGzip(),
      fs.createWriteStream("big.csv.gz"),
    );
    

    Ứng dụng: upload/download file lớn, xuất CSV từ DB theo con trỏ, proxy dữ liệu.

    Hay sai: dùng fs.readFile cho file lớn, hoặc .pipe() nối nhiều bước mà không bắt lỗi ở từng stream, khiến file descriptor bị rò.

  7. 07Một endpoint làm tác vụ nặng CPU (xử lý ảnh, tạo báo cáo) làm cả server chậm theo. Bạn xử lý thế nào?

    Gợi ý trả lời

    Tác vụ CPU chạy trên main thread sẽ chặn event loop, nên mọi request khác phải chờ. Có vài hướng:

    • worker_threads: chạy tính toán ở thread khác trong cùng process. Nên dùng một pool worker (ví dụ thư viện Piscina) thay vì tạo worker mới cho mỗi request.
    • Hàng đợi công việc: API chỉ nhận yêu cầu, đẩy job vào queue (BullMQ trên Redis, RabbitMQ), trả về 202 kèm mã job; worker riêng xử lý, client hỏi lại trạng thái hoặc nhận thông báo.
    • Tách service: nếu việc nặng là trọng tâm, viết bằng ngôn ngữ phù hợp hơn hoặc dùng dịch vụ chuyên dụng.

    cluster hay PM2 chạy nhiều process giúp tận dụng nhiều core, nhưng không giải quyết việc một request nặng chặn process của nó.

    Người phỏng vấn muốn nghe: bạn đo được độ trễ event loop (perf_hooks.monitorEventLoopDelay) để phát hiện vấn đề này.

  8. 08Trong NestJS, module, provider và dependency injection hoạt động ra sao? Guard, interceptor, pipe khác nhau thế nào?

    Gợi ý trả lời

    NestJS tổ chức app thành module. Mỗi module khai báo controller, provider (service, repository) và exports những gì module khác được dùng. IoC container của Nest tự tạo provider và inject qua constructor.

    @Injectable()
    export class OrdersService {
      constructor(private readonly repo: OrdersRepository) {}
    }
    

    Thứ tự một request đi qua: middleware, guard, interceptor (trước), pipe, handler, interceptor (sau); exception filter bắt lỗi ở bất kỳ bước nào.

    • Guard: quyết định request có được đi tiếp không (xác thực, phân quyền).
    • Pipe: biến đổi và validate input (ValidationPipe với DTO).
    • Interceptor: bọc quanh handler: log, đo thời gian, đổi định dạng response, cache.

    Hay sai: hai provider phụ thuộc vòng tròn rồi xử lý bằng forwardRef khắp nơi. Thường đó là dấu hiệu nên tách lại trách nhiệm giữa các module.

  9. 09Process Node trên production tăng RAM liên tục rồi bị kill. Bạn tìm memory leak thế nào?Senior

    Gợi ý trả lời

    Trước hết xác nhận có leak thật: theo dõi heap qua thời gian (process.memoryUsage(), metrics của APM). Heap tăng rồi giảm sau GC là bình thường; chỉ tăng không giảm mới là leak.

    Nguyên nhân hay gặp:

    • Cache trong bộ nhớ (Map, object) không giới hạn kích thước hoặc thời hạn.
    • Event listener đăng ký mỗi request mà không gỡ (thường kèm cảnh báo MaxListenersExceededWarning).
    • Closure giữ object lớn; timer không clearInterval.
    • Mảng toàn cục lưu dữ liệu request để debug rồi quên xoá.

    Cách điều tra:

    1. Chạy với --inspect, mở Chrome DevTools, chụp heap snapshot trước và sau khi bắn tải, so sánh object tăng thêm và chuỗi retainers.
    2. Dùng --heapsnapshot-near-heap-limit để tự chụp snapshot khi sắp hết bộ nhớ.

    Người phỏng vấn muốn nghe: cache thì dùng LRU có giới hạn hoặc Redis, và biết rằng tăng --max-old-space-size chỉ làm lỗi đến chậm hơn.

  10. 10Bạn mở rộng (scale) một API Node.js thế nào? Graceful shutdown là gì và vì sao cần?Senior

    Gợi ý trả lời

    Node một process chỉ dùng tốt một core, nên scale theo chiều ngang:

    • Chạy nhiều process (cluster, PM2) hoặc nhiều container sau load balancer, Kubernetes tự tăng giảm số pod.
    • App phải stateless: session, cache, rate limit, lock để ở Redis hoặc DB, không để trong bộ nhớ process.
    • WebSocket nhiều instance cần adapter qua Redis pub/sub để gửi tin giữa các instance.
    • Đặt giới hạn connection pool DB theo tổng số instance.

    Graceful shutdown: khi nhận SIGTERM (deploy, scale down), ngừng nhận request mới, chờ request đang chạy xong, đóng DB và queue, rồi thoát:

    process.on("SIGTERM", () => {
      server.close(async () => {
        await db.close();
        process.exit(0);
      });
    });
    

    Hay sai: không xử lý SIGTERM, mỗi lần deploy lại cắt ngang request và job đang làm dở. Nên đặt thêm timeout buộc thoát nếu chờ quá lâu.

  11. 11Khi xây dựng API bằng Node.js, bạn chú ý những vấn đề bảo mật nào?Senior

    Gợi ý trả lời

    • Validate mọi input ở biên bằng schema (Zod, Joi, class-validator trong NestJS); chỉ nhận field cho phép để tránh mass assignment.
    • Injection: dùng query có tham số hoặc ORM, không nối chuỗi SQL. Với MongoDB, chặn object toán tử như { "$gt": "" } lọt vào điều kiện truy vấn.
    • Xác thực và phân quyền: kiểm tra quyền trên từng tài nguyên (user này có sở hữu đơn hàng này không), không chỉ kiểm tra đã đăng nhập.
    • Rate limiting cho login, OTP, API tốn tài nguyên.
    • Header bảo mật (Helmet), CORS chỉ mở cho origin cần thiết.
    • Secret để trong biến môi trường hoặc secret manager, không commit; không log token, mật khẩu.
    • Dependency: chạy npm audit hoặc công cụ quét trong CI, khoá phiên bản bằng lockfile.

    Người phỏng vấn muốn nghe: lỗi phân quyền theo đối tượng (IDOR) rất phổ biến, và bạn biết cách phòng nó.

  12. 12Thiết kế xử lý job nền (gửi email, đồng bộ dữ liệu) trong hệ thống Node.js thế nào để không mất job và không xử lý trùng?Senior

    Gợi ý trả lời

    Dùng message queue thay vì xử lý ngay trong request: BullMQ (Redis), RabbitMQ hoặc dịch vụ queue của cloud. API đẩy job rồi trả về ngay; worker riêng xử lý.

    Những điểm cần thiết kế:

    • Retry có backoff (tăng dần thời gian chờ) và giới hạn số lần thử; job vẫn lỗi thì đưa vào dead-letter queue để xem lại.
    • Idempotency: hầu hết queue đảm bảo "ít nhất một lần", nên job có thể chạy hai lần. Dùng khoá idempotency hoặc kiểm tra trạng thái trước khi làm (ví dụ email đã gửi chưa).
    • Không mất job khi ghi DB: ghi dữ liệu và "sự kiện cần gửi" trong cùng transaction (outbox pattern), rồi một tiến trình khác đẩy sang queue.
    • Giới hạn concurrency của worker, timeout cho từng job.

    Hay sai: dùng setTimeout hay mảng trong bộ nhớ làm "queue". Restart hoặc deploy là mất hết job.

Gặp câu hỏi khác khi phỏng vấn? Kể lại ẩn danh để người sau ôn đúng trọng tâm, hoặc xem câu hỏi thật hay gặp ở nhiều công ty.