Phỏng Vấn & Sự Nghiệp • 13 phút đọc • 08/10/2026

Phỏng Vấn Frontend Mid: JavaScript Nền Tảng Sâu — Event Loop, Closure và Những Câu Bẫy

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Trình soạn thảo mã
Ảnh: Kindohm · CC BY-SA 4.0

Vòng JavaScript nền tảng là chỗ nhiều ứng viên mid trượt nhất, dù CV ghi hai ba năm React, Vue đủ cả. Không phải họ không biết JavaScript. Họ biết đủ để viết code chạy được. Nhưng không đủ để nói vì sao nó chạy như vậy. Interviewer cũng chẳng hỏi định nghĩa. Họ đưa một đoạn code mười dòng rồi hỏi “in ra gì, theo thứ tự nào, vì sao”.

Mấy chủ đề dưới đây quay đi quay lại trong các vòng mid: event loop, closure, this, prototype, async/await với các Promise combinator. Mỗi mục vẫn theo nhịp câu hỏi, câu trả lời kiểu “biết nhưng chưa sâu”, câu trả lời đủ chiều sâu, rồi follow-up bẫy. Output trong bài mình chạy lại trên Node.js, không đoán.

Mid khác Senior ở đâu trong vòng này

Nói kỳ vọng trước, vì nhiều bạn chuẩn bị sai chỗ.

Kỳ vọng với Mid:

  • Đoán đúng output của đoạn code có setTimeout, Promise, async/await trộn lẫn.
  • Giải thích closure bằng ví dụ thật — vòng lặp, debounce, factory — chứ không đọc định nghĩa suông.
  • Biết this được quyết định lúc gọi, không phải lúc định nghĩa (trừ arrow function).
  • Phân biệt được Promise.all, allSettled, race, any và chọn đúng cái cần dùng.
  • Tự sửa được bug kiểu stale value, forEach + await, quên return trong .then.

Kỳ vọng thêm với Senior:

  • Giải thích được vì sao có hai hàng đợi — microtask và macrotask — và hệ quả: microtask có thể bỏ đói rendering.
  • Chỉ ra được rủi ro vận hành: unhandled rejection, memory leak do closure giữ tham chiếu, long task chặn main thread.
  • Thiết kế được cơ chế hủy (AbortController), giới hạn đồng thời (concurrency limit), retry có backoff.
  • Biết đoạn code nào khác nhau giữa trình duyệt và Node (thứ tự setImmediate, process.nextTick).

Chỗ trượt hay nằm ở đây: trả lời đúng “cái gì”, nhưng bí ngay khi interviewer đổi một chi tiết nhỏ trong đề và hỏi “vì sao”.

1. Event loop: thứ tự in log

Interviewer hỏi: “Đoạn code này in ra thứ tự nào?”

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

queueMicrotask(() => console.log("4"));

(async () => {
  console.log("5");
  await null;
  console.log("6");
})();

console.log("7");

Trả lời kiểu “biết nhưng chưa sâu”: “Code đồng bộ chạy trước, rồi Promise, rồi setTimeout.” Đúng hướng. Nhưng đề có queueMicrotask với await là bắt đầu đoán mò, và sai ngay vị trí số 6.

Câu trả lời đủ chiều sâu: mô hình mình luôn vẽ ra giấy có ba phần: call stack, microtask queue và macrotask queue (task queue). Một vòng đi như sau. Chạy hết script đồng bộ hiện tại — bản thân nó cũng là một macrotask. Sau đó xả sạch microtask queue cho tới khi rỗng. Trong trình duyệt thì tới lượt render. Rồi mới lấy macrotask kế tiếp.

Áp dụng vào đề:

  1. Phần đồng bộ in 1, 5 (phần của async function trước await chạy đồng bộ), 7.
  2. Microtask được xếp hàng theo thứ tự đăng ký: callback của .then (in 3), queueMicrotask (in 4), và phần tiếp theo sau await null (in 6).
  3. Hết microtask mới tới macrotask: setTimeout in 2.

Kết quả: 1 5 7 3 4 6 2.

Đi hết một vòng thì nó như thế này:

flowchart TD
  A["Chạy hết script đồng bộ<br/>in 1, 5, 7"] --> B["Xả sạch microtask queue<br/>in 3, 4, rồi 6"]
  B --> C{"microtask queue còn gì?"}
  C -->|còn| B
  C -->|rỗng| D["Trình duyệt có thể render"]
  D --> E["Lấy macrotask kế tiếp<br/>setTimeout in 2"]
  E --> A

await x thực chất tách hàm ra làm hai nửa. Nửa sau là một microtask, được xếp vào hàng khi x đã resolved. Ứng viên mid hay nhầm await là “tạm dừng toàn bộ chương trình”. Không phải. Nó chỉ tạm dừng hàm đó, còn call stack trả về cho caller chạy tiếp.

Follow-up bẫy: “Nếu trong một microtask lại queueMicrotask tiếp, rồi cứ thế đệ quy thì chuyện gì xảy ra?” Microtask queue phải rỗng thì event loop mới đi tiếp. Nên một chuỗi microtask vô hạn sẽ chặn render và chặn luôn mọi macrotask — trang đơ, dù chẳng có vòng while nào. Microtask không “công bằng” như macrotask.

2. async/await và số tick “ẩn”

Interviewer hỏi: “Đề khó hơn một chút, in ra gì?”

async function a1() {
  console.log("a1 start");
  await a2();
  console.log("a1 end");
}

async function a2() {
  console.log("a2");
}

console.log("script start");

setTimeout(() => console.log("timeout"), 0);

a1();

new Promise((resolve) => {
  console.log("p1");
  resolve();
})
  .then(() => console.log("then1"))
  .then(() => console.log("then2"));

console.log("script end");

Output thực tế:

script start
a1 start
a2
p1
script end
a1 end
then1
then2
timeout

Câu trả lời đủ chiều sâu: executor của new Promise chạy đồng bộ, nên p1 in ngay trong đoạn đồng bộ. a2() cũng chạy đồng bộ, trả về một native promise đã resolved. await trên native promise tốn đúng một tick trong engine hiện đại (V8 tối ưu từ khoảng 2018, trước đó tốn tới ba tick). await a2() được đăng ký trước .then(() => console.log("then1")), nên a1 end đứng trước then1. then2 phải đợi then1 xong mới được xếp hàng. Nó luôn ở sau.

Follow-up bẫy: “Nếu a2 trả về một thenable thay vì native promise thì sao?” Thenable là object có .then. Engine phải gọi .then của nó trong một microtask riêng, tốn thêm tick, nên a1 end có thể tụt xuống sau then1. Bạn không cần nhớ số tick chính xác. Interviewer chỉ muốn biết bạn có nắm chỗ này: thứ tự phụ thuộc vào việc giá trị là native promise hay thenable. Còn khi viết code thật thì đừng để nó phụ thuộc vào số tick — nhưng phải đọc được.

3. Closure: vòng lặp, var và let

Interviewer hỏi: “Hai vòng lặp này khác nhau thế nào?”

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var", i), 0);
}

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let", j), 0);
}

Output: var 3, var 3, var 3, rồi let 0, let 1, let 2.

Trả lời kiểu “biết nhưng chưa sâu”: “var có function scope còn let có block scope.” Đúng, nhưng chưa trả lời được câu hỏi thật: tại sao callback nhìn thấy 3?

Câu trả lời đủ chiều sâu: closure là hàm giữ tham chiếu tới môi trường (scope) nơi nó được tạo, chứ không giữ giá trị tại thời điểm tạo. Với var, cả ba callback cùng trỏ vào một biến i duy nhất ở function scope. Tới lúc setTimeout chạy thì vòng lặp đã xong, i === 3. Với let, theo đặc tả, mỗi lần lặp tạo một binding mới cho j — copy giá trị từ lần trước rồi mới tăng — nên mỗi callback có một j riêng.

Hai cơ chế đó khác nhau ở chỗ giữ biến:

flowchart TD
  V["var i ở function scope"] --> V1["ba callback cùng trỏ vào một biến i"]
  V1 --> V2["setTimeout chạy sau khi vòng lặp xong: cả ba in 3"]
  L["let j: mỗi lần lặp một binding mới"] --> L1["mỗi callback giữ binding riêng"]
  L1 --> L2["lần lượt in 0, 1, 2"]

Cách sửa kiểu cũ vẫn xuất hiện trong codebase legacy là IIFE:

for (var k = 0; k < 3; k++) {
  ((captured) => {
    setTimeout(() => console.log("iife", captured), 0);
  })(k);
}

Closure thực chiến ít nằm ở vòng lặp, mà ở debounce, memoize, factory:

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

function createCounter() {
  let count = 0;
  return {
    inc: () => ++count,
    get: () => count,
  };
}

timer với count là biến “private” thật sự. Không có đường nào chạm vào chúng ngoài mấy hàm trả về.

Follow-up bẫy: “Closure có gây memory leak không?” Bản thân closure không leak. Nó giữ sống mọi biến mà nó — hoặc các closure cùng scope — tham chiếu. Ví dụ hay gặp: event listener đăng ký trong component mà quên gỡ, callback giữ một mảng dữ liệu lớn ở scope cha. Xử lý: gỡ listener ở cleanup, đặt biến lớn về null khi xong, và đo bằng heap snapshot trong DevTools chứ đừng đoán.

4. this: quyết định lúc gọi

Interviewer hỏi: “Mỗi lệnh gọi dưới đây in ra gì?”

"use strict";

const user = {
  name: "An",
  regular() {
    return this?.name;
  },
  arrowInside() {
    const inner = () => this.name;
    return inner();
  },
};

const detached = user.regular;

console.log(user.regular());          // "An"
console.log(detached());              // undefined
console.log(user.arrowInside());      // "An"

const bound = detached.bind({ name: "Bình" });
console.log(bound());                 // "Bình"
console.log(bound.call({ name: "Chi" })); // "Bình"

Trả lời kiểu “biết nhưng chưa sâu”: “this là object đứng trước dấu chấm.” Quy tắc đó đúng với user.regular(). Tới detached() là sập.

Câu trả lời đủ chiều sâu: với hàm thường, this do cách gọi (call-site) quyết định. Thứ tự ưu tiên mình hay trình bày: new > ràng buộc tường minh (call, apply, bind) > method call (obj.fn()) > gọi trơn (fn()). Gọi trơn trong strict mode cho this === undefined; sloppy mode thì ra globalThis. Nên trong ví dụ mình để "use strict", và nói rõ khác biệt này để khỏi tranh luận.

Arrow function không có this riêng. Nó lấy this từ scope bao quanh lúc định nghĩa (lexical this), nên inner() trong arrowInside dùng this của method. bind tạo hàm mới với this bị khoá. Gọi .call lên hàm đã bind cũng không đổi được this — vì thế dòng cuối vẫn ra “Bình”.

Lỗi thực chiến hay gặp nhất: mất this khi truyền method làm callback:

class Timer {
  seconds = 0;

  tickBroken() {
    this.seconds++;
  }

  tick = () => {
    this.seconds++;
  };
}

const t = new Timer();
setInterval(t.tick, 1000);          // ổn: arrow field giữ this
// setInterval(t.tickBroken, 1000); // lỗi: this không còn là t

Follow-up bẫy: “Vì sao không dùng arrow function cho mọi method trong object literal?” Arrow không có this riêng, nên const o = { name: "x", hi: () => this.name } không trỏ vào o. Arrow cũng không làm constructor được, và không có arguments. Còn class field arrow thì mỗi instance một bản hàm riêng — tốn bộ nhớ hơn method trên prototype, và khó mock khi test.

5. Prototype: “class” thực chất là gì

Interviewer hỏi: “class trong JavaScript khác gì function constructor? Method nằm ở đâu?”

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  speak() {
    return `${super.speak()} - woof`;
  }
}

const d = new Dog("Mực");

console.log(Object.hasOwn(d, "name"));            // true
console.log(Object.hasOwn(d, "speak"));           // false
console.log(Object.getPrototypeOf(d) === Dog.prototype);                    // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);     // true
console.log(typeof Dog);                          // "function"

Trả lời kiểu “biết nhưng chưa sâu”: “class là syntax sugar của prototype.” Ngắn, đúng một nửa, và interviewer hỏi tiếp ngay.

Câu trả lời đủ chiều sâu: mỗi object có một liên kết nội bộ [[Prototype]], truy cập qua Object.getPrototypeOf. Đọc d.speak, engine tìm trên chính d trước; không thấy thì đi theo chuỗi [[Prototype]] sang Dog.prototype, thấy speak thì dừng. Dữ liệu (name) là own property của instance. Method nằm trên prototype nên dùng chung cho mọi instance. Dog.prototype lại có [[Prototype]] là Animal.prototype, cuối chuỗi là Object.prototype rồi null.

Chuỗi đó đi hết một lượt thì như thế này:

flowchart LR
  D["d — own property: name"] --> DP["Dog.prototype — speak"]
  DP --> AP["Animal.prototype"]
  AP --> OP["Object.prototype"]
  OP --> NU["null"]

“Syntax sugar” chỉ đúng một phần. Class khác function constructor ở mấy điểm có thật: không hoisting kiểu gọi được (nằm trong temporal dead zone), luôn chạy strict mode, thiếu new thì không gọi được, method không enumerable. Còn extends thì thiết lập chuỗi prototype cho cả constructor (Object.getPrototypeOf(Dog) === Animal), để static member kế thừa được.

Cách tạo chuỗi prototype không cần class:

const base = {
  greet() {
    return `Xin chào, ${this.name}`;
  },
};

const child = Object.create(base, {
  name: { value: "Lan", enumerable: true },
});

console.log(child.greet());                       // "Xin chào, Lan"
console.log(Object.hasOwn(child, "greet"));       // false

Follow-up bẫy: “Vì sao for...in đôi khi in ra property không phải của object?” Vì for...in duyệt cả enumerable property kế thừa trên chuỗi prototype. Dùng Object.keys, Object.entries, hoặc lọc bằng Object.hasOwn. Ý cộng điểm: đừng sửa prototype của built-in — monkey patching Array.prototype đổi hành vi của mọi code khác trong app, kể cả thư viện bên thứ ba.

6. async/await trong thực chiến: ba lỗi kinh điển

Interviewer hỏi: “Đoạn code này có vấn đề gì?”

async function sendAll(users) {
  users.forEach(async (u) => {
    await sendEmail(u);
  });
  console.log("done");
}

Trả lời kiểu “biết nhưng chưa sâu”: “Hình như nó in done trước.” Đúng triệu chứng. Nguyên nhân với cách sửa thì chịu.

Câu trả lời đủ chiều sâu: forEach bỏ qua giá trị trả về của callback, nên nó không đợi mấy promise mà async callback trả ra. sendAll kết thúc ngay. done in trước khi email nào được gửi, còn lỗi của sendEmail thành unhandled rejection vì không ai bắt. Sửa thế nào thì tuỳ ý đồ:

// Tuần tự: email sau đợi email trước
async function sendSequential(users) {
  for (const u of users) {
    await sendEmail(u);
  }
}

// Song song: tất cả cùng chạy, đợi hết
async function sendParallel(users) {
  await Promise.all(users.map((u) => sendEmail(u)));
}

Lỗi thứ hai: vô tình tuần tự hoá thứ lẽ ra chạy song song.

// Chậm: tổng thời gian = a + b
const a = await fetchA();
const b = await fetchB();

// Nhanh hơn khi hai việc độc lập: tổng thời gian = max(a, b)
const [a2, b2] = await Promise.all([fetchA(), fetchB()]);

Lỗi thứ ba: quên return hoặc quên await trong try/catch.

async function load() {
  try {
    return fetchData(); // không await: lỗi KHÔNG bị catch ở đây
  } catch (e) {
    console.error("không bao giờ chạy với lỗi bất đồng bộ", e);
  }
}

async function loadFixed() {
  try {
    return await fetchData(); // có await: catch bắt được rejection
  } catch (e) {
    console.error("bắt được", e);
    throw e;
  }
}

Follow-up bẫy: “Song song hóa 5.000 request bằng Promise.all(ids.map(fetchOne)) có ổn không?” Không ổn. Bắn 5.000 request cùng lúc là chạm giới hạn kết nối của trình duyệt, rate limit của server, hoặc nuốt sạch bộ nhớ. Câu trả lời senior là một concurrency limit: chia lô, hoặc viết một pool nhỏ.

async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function run() {
    while (next < items.length) {
      const index = next++;
      results[index] = await worker(items[index], index);
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
  return results;
}

JavaScript đơn luồng, nên next++ không race giữa các worker. Chuyển ngữ cảnh chỉ xảy ra tại điểm await.

7. Promise combinators: chọn đúng công cụ

Interviewer hỏi: “Khác nhau giữa all, allSettled, race, any? Cho ví dụ output.”

const ok = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));
const fail = (m, ms) => new Promise((_, rej) => setTimeout(() => rej(new Error(m)), ms));

const tasks = () => [ok("A", 30), fail("B lỗi", 10), ok("C", 20)];

Promise.all(tasks()).catch((e) => console.log("all:", e.message));
// all: B lỗi

Promise.allSettled(tasks()).then((rs) =>
  console.log("allSettled:", rs.map((r) => r.status))
);
// allSettled: [ 'fulfilled', 'rejected', 'fulfilled' ]

Promise.race(tasks()).catch((e) => console.log("race:", e.message));
// race: B lỗi

Promise.any(tasks()).then((v) => console.log("any:", v));
// any: C

Câu trả lời đủ chiều sâu:

CombinatorResolve khiReject khi
Promise.alltất cả thành công, trả mảng theo đúng thứ tự đầu vàomột promise reject (fail-fast, trả lỗi đầu tiên)
Promise.allSettledtất cả đã xong dù thành công hay lỗikhông bao giờ reject
Promise.racepromise đầu tiên xong là fulfilledpromise đầu tiên xong là rejected
Promise.anypromise đầu tiên thành côngtất cả reject (ném AggregateError)

Chọn theo ý đồ. Cần đủ kết quả hợp lệ mới đi tiếp thì all. Cần báo cáo từng phần — tải nhiều widget, widget nào lỗi thì hiện lỗi ở widget đó — thì allSettled. Cần timeout thì race. Cần lấy mirror nhanh nhất còn sống thì any.

Timeout bằng race là câu hỏi mở rộng hay gặp, và có một cái bẫy:

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timeout sau ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

Follow-up bẫy: “Thua cuộc đua thì promise còn lại có bị hủy không?” Không. race chỉ chọn kết quả. Request thật vẫn chạy tiếp và vẫn tốn băng thông. Muốn hủy thật thì phải truyền AbortSignal vào fetch và gọi controller.abort() khi hết giờ. Hủy là chuyện của tầng thực thi, không phải của Promise.

8. Bài tập thêm: tự đoán trước khi xem đáp án

Hai đề dưới đây mình hay dùng để phân biệt hiểu và thuộc. Đoán trên giấy trước, rồi hẵng chạy.

Đề A:

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve()
  .then(() => {
    console.log("C");
    setTimeout(() => console.log("D"), 0);
  })
  .then(() => console.log("E"));

setTimeout(() => console.log("F"), 0);

console.log("G");

Đáp án: A G C E B F D. Phần đồng bộ in A, G. Microtask tới lượt C — và ngay lúc đó macrotask D được đăng ký vào cuối hàng đợi — rồi E. Macrotask chạy theo thứ tự đăng ký: B, F, và D đứng cuối vì được đăng ký muộn nhất, dù delay cũng là 0.

Đề B:

const p = new Promise((resolve, reject) => {
  resolve("first");
  reject(new Error("ignored"));
  resolve("second");
});

p.then((v) => {
  console.log(v);
  return Promise.reject(new Error("boom"));
})
  .catch((e) => {
    console.log(e.message);
    return "recovered";
  })
  .then((v) => console.log(v))
  .finally(() => console.log("finally"));

Đáp án: first, boom, recovered, finally. Promise chỉ settle một lần, nên lời gọi reject và resolve thứ hai bị bỏ qua. .catch trả về giá trị thường, chuỗi quay lại trạng thái fulfilled. finally không nhận giá trị, cũng không đổi kết quả của chuỗi — trừ khi nó ném lỗi hoặc trả về một promise bị reject.

Vì sao ứng viên mid “biết” mà vẫn trượt

Qua nhiều vòng, mấy nguyên nhân này lặp lại hoài:

  1. Học thuộc định nghĩa, không tự chạy code. “Closure là hàm nhớ scope” thì ai cũng nói được. Nhưng khi đề đổi var thành let hoặc thêm một await, người chưa từng tự chạy và đoán trước sẽ đứng hình. Thói quen tốt nhất: viết đoạn code, ghi dự đoán ra giấy, chạy, và nếu sai thì tìm cho ra cơ chế.
  2. Đoán thay vì lần theo mô hình. Ứng viên mạnh vẽ ba cột: stack, microtask, macrotask, và điền từng bước. Ứng viên yếu “nhìn có vẻ” rồi chốt. Interviewer nhìn quy trình suy nghĩ nhiều hơn đáp án cuối cùng.
  3. Dừng ở câu trả lời một tầng. Câu follow-up luôn xuất hiện: “nếu đổi X thì sao”. Chuẩn bị bằng cách tự hỏi sau mỗi kết luận: điều gì làm kết luận này sai?
  4. Không gắn với tình huống thực. Khi được hỏi “closure dùng để làm gì”, câu trả lời “để nhớ biến” kém hơn câu trả lời “mình dùng nó để làm debounce cho ô tìm kiếm, và từng gặp bug vì quên clearTimeout”.
  5. Hoảng khi không biết. Nói thẳng “chỗ này mình chưa chắc, mình suy luận thế này” ghi điểm hơn đoán liều. Kỹ năng suy luận từ nguyên lý chính là thứ đang được đánh giá.

Checklist ôn tập một tuần

  • Vẽ lại mô hình event loop và tự giải thích cho một người không làm kỹ thuật nghe (nếu nói được, bạn hiểu thật).
  • Làm ít nhất mười đề đoán output, mỗi đề tự viết dự đoán trước. Trộn setTimeout, then, async/await, queueMicrotask.
  • Viết tay debounce, throttle, memoize, bind polyfill, và Promise.all polyfill. Mỗi hàm đều chạm vào closure, this hoặc Promise.
  • Đọc phần async của MDN và đặc tả liên quan để hiểu thứ tự job, đừng chỉ đọc blog tóm tắt.
  • Với mỗi chủ đề, chuẩn bị một câu chuyện lỗi có thật từ dự án của chính bạn. Một bug bạn đã thực sự gặp và sửa có sức thuyết phục hơn mười định nghĩa.

Phỏng vấn mid không đòi bạn thuộc đặc tả ECMAScript. Nó đòi một mô hình tinh thần đủ chính xác để đoán hành vi của code, và đủ thành thật để nói rõ chỗ mình chưa chắc. Ba thành phần trên giấy — stack, microtask, macrotask — là đủ để bắt đầu.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.