Lần đầu team mình dùng feature flag nghiêm túc là sau một lần release tối thứ Sáu phải rollback lúc nửa đêm. Từ đó, mọi tính năng lớn đều nằm sau một flag: deploy lúc nào cũng được, bật cho ai, khi nào là một quyết định riêng. Nhưng hai năm sau, codebase có hơn trăm flag, một nửa không ai nhớ còn dùng không, và vài trang nhấp nháy giao diện mỗi lần tải. Bài này là những gì mình học được để dùng flag mà không bị flag "dùng" lại mình.

Không phải flag nào cũng giống nhau#

Gọi chung là "feature flag" nhưng thật ra có ít nhất bốn loại, với vòng đời và người sở hữu rất khác nhau. Trộn lẫn chúng là nguồn gốc của phần lớn rắc rối.

LoạiMục đíchSống bao lâuAi bật/tắt
Release flagẨn tính năng chưa xong, rollout dầnVài ngày đến vài tuầnTeam phát triển
Experiment flagA/B test, so sánh các biến thểĐúng thời gian thí nghiệmProduct, data
Ops flagKill switch, tắt tính năng nặng khi hệ thống quá tảiLâu dài, có chủ đíchOn-call, SRE
Permission flagBật tính năng theo gói, theo khách hàng, betaLâu dàiProduct, sales

Hai điểm mình rút ra từ bảng này:

  • Release và experiment flag phải chết. Chúng là tạm thời theo định nghĩa. Một release flag sống sáu tháng là dấu hiệu có vấn đề.
  • Permission flag thật ra là logic nghiệp vụ. Nếu "khách hàng gói Pro thấy báo cáo nâng cao" là quy tắc vĩnh viễn, nó nên nằm trong hệ thống phân quyền, không phải công cụ feature flag. Và quan trọng hơn: ẩn nút ở frontend không phải là bảo mật — API phía sau vẫn phải kiểm tra quyền.

Định nghĩa flag có kiểu bằng TypeScript#

Lỗi kinh điển: code gọi isEnabled("new-chekout") — sai chính tả — và flag mãi mãi là false mà không ai biết. Mình luôn bắt đầu bằng một file định nghĩa tập trung, có kiểu chặt chẽ:

src/flags/definitions.ts
export type FlagKind = "release" | "experiment" | "ops" | "permission";

export type FlagValues = {
  newCheckout: boolean;
  checkoutCopy: "control" | "short" | "benefit-led";
  disableRecommendations: boolean;
};

export type FlagKey = keyof FlagValues;

type FlagDefinition<T> = {
  kind: FlagKind;
  defaultValue: T;
  owner: string;
  description: string;
  /** Bắt buộc với release và experiment flag, định dạng YYYY-MM-DD. */
  expiresAt?: string;
};

export const flagDefinitions: { [K in FlagKey]: FlagDefinition<FlagValues[K]> } = {
  newCheckout: {
    kind: "release",
    defaultValue: false,
    owner: "team-checkout",
    description: "Luồng checkout mới một trang",
    expiresAt: "2026-07-01",
  },
  checkoutCopy: {
    kind: "experiment",
    defaultValue: "control",
    owner: "team-growth",
    description: "Thử nghiệm nội dung nút thanh toán",
    expiresAt: "2026-06-15",
  },
  disableRecommendations: {
    kind: "ops",
    defaultValue: false,
    owner: "team-platform",
    description: "Kill switch: tắt khối gợi ý sản phẩm khi API quá tải",
  },
};

Lợi ích có được ngay:

  • Gõ sai tên flag là lỗi biên dịch. Giá trị của checkoutCopy là union type, nên switch thiếu biến thể cũng bị TypeScript nhắc (nếu bạn dùng kiểm tra exhaustive).
  • Giá trị mặc định nằm trong code. Nếu dịch vụ flag sập hoặc chậm, ứng dụng vẫn chạy với giá trị an toàn. Mặc định của release flag gần như luôn là "tắt".
  • Mỗi flag có chủ sở hữu và ngày hết hạn — nền tảng cho quy trình dọn dẹp ở cuối bài.

Đánh giá flag ở đâu: server hay client?#

Đây là quyết định kiến trúc quan trọng nhất, vì nó ảnh hưởng trực tiếp đến trải nghiệm người dùng.

Đánh giá ở client (SDK chạy trong trình duyệt, gọi API lấy flag sau khi trang tải): dễ tích hợp, nhưng có một vấn đề lớn — trong khoảng thời gian chờ flag, giao diện phải render cái gì đó. Render phiên bản mặc định rồi đổi sang phiên bản mới khi flag về chính là hiện tượng nhấp nháy (flicker): người dùng thấy giao diện cũ trong một khoảnh khắc rồi nhảy sang giao diện mới. Nó gây layout shift, làm hỏng CLS, và — với A/B test — làm sai lệch kết quả thí nghiệm.

Đánh giá ở server (trong Server Component, middleware hoặc API render HTML): HTML gửi về đã đúng phiên bản ngay từ đầu, không có nhấp nháy. Cái giá là trang phụ thuộc vào người dùng, nên khó cache toàn bộ ở CDN.

Cách mình hay làm trong Next.js App Router: đánh giá tất cả flag một lần trên server, rồi bootstrap giá trị xuống client qua một provider, để Client Component đọc được ngay trong lần render đầu — không cần request thứ hai.

app/layout.tsx
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { evaluateFlags } from "@/flags/server";
import { FlagsProvider } from "@/flags/client";

export default async function RootLayout({ children }: { children: ReactNode }) {
  const cookieStore = await cookies();
  const userId = cookieStore.get("uid")?.value ?? "anonymous";
  const flags = await evaluateFlags({ userId });

  return (
    <html lang="vi">
      <body>
        <FlagsProvider value={flags}>{children}</FlagsProvider>
      </body>
    </html>
  );
}
src/flags/client.tsx
"use client";

import { createContext, useContext, type ReactNode } from "react";
import type { FlagKey, FlagValues } from "./definitions";

const FlagsContext = createContext<FlagValues | null>(null);

export function FlagsProvider({ value, children }: { value: FlagValues; children: ReactNode }) {
  return <FlagsContext.Provider value={value}>{children}</FlagsContext.Provider>;
}

export function useFlag<K extends FlagKey>(key: K): FlagValues[K] {
  const flags = useContext(FlagsContext);
  if (!flags) throw new Error("useFlag phải được dùng bên trong FlagsProvider");
  return flags[key];
}

Vì server và client dùng cùng một bộ giá trị, HTML được render trên server khớp với lần render đầu ở client — không có hydration mismatch, không có nhấp nháy.

Đọc cookie trong layout sẽ khiến route render động theo từng request. Nếu trang cần cache tĩnh, có hai hướng: chỉ đánh giá flag ở những route thật sự cần, hoặc dùng middleware chia người dùng vào một số ít biến thể rồi cache từng biến thể. Đừng để một flag nhỏ vô tình biến cả site thành dynamic.

Khi buộc phải đánh giá ở client (ví dụ trang tĩnh hoàn toàn), giảm nhấp nháy bằng cách: giữ chỗ cho vùng có flag với kích thước cố định, hiển thị skeleton thay vì phiên bản mặc định, và chỉ dùng flag cho phần nằm dưới màn hình đầu tiên nếu có thể. Tuyệt đối tránh kiểu "ẩn cả trang cho đến khi flag tải xong" — đó là cách nhanh nhất để làm hỏng LCP.

Rollout dần và kill switch#

Giá trị lớn nhất của release flag là không phải bật cho tất cả mọi người cùng lúc. Một kế hoạch rollout điển hình: nội bộ → 1% → 10% → 50% → 100%, mỗi bước theo dõi tỉ lệ lỗi, Web Vitals và chỉ số kinh doanh trước khi đi tiếp.

Điều kiện quan trọng: phân nhóm phải ổn định. Người dùng đã thấy giao diện mới thì lần sau vào vẫn phải thấy nó — không được nhảy qua lại mỗi lần tải trang. Cách phổ biến là băm ID người dùng thành một số từ 0 đến 99:

TypeScript
// Băm FNV-1a 32-bit: nhanh, đủ phân bố đều cho mục đích chia nhóm (không dùng cho bảo mật).
function bucketOf(input: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0) % 100;
}

export function isInRollout(flagKey: string, userId: string, percentage: number): boolean {
  // Ghép tên flag vào để mỗi flag chia nhóm khác nhau,
  // tránh việc cùng một nhóm 1% người dùng luôn là "chuột bạch" cho mọi tính năng.
  return bucketOf(`${flagKey}:${userId}`) < percentage;
}

Tăng từ 10% lên 50% thì những người trong nhóm 10% vẫn giữ nguyên, vì bucket của họ không đổi. Với người dùng chưa đăng nhập, bạn cần một ID ẩn danh ổn định (thường lưu trong cookie).

Kill switch là ops flag cho phép tắt một tính năng trong vài giây mà không cần deploy. Chúng chỉ có ích nếu: thay đổi giá trị có hiệu lực nhanh (cache flag không quá lâu), code xử lý được trạng thái "tắt" một cách đẹp đẽ, và on-call biết flag đó tồn tại. Mình từng thấy kill switch không ai dám bật vì chưa từng được test — hãy thử bật nó ít nhất một lần trên staging.

A/B test: những cái bẫy cần biết#

Experiment flag nhìn giống release flag, nhưng kết luận rút ra từ nó cần cẩn thận hơn nhiều:

  • Ghi nhận exposure đúng lúc: chỉ tính một người vào thí nghiệm khi họ thật sự thấy biến thể, không phải khi được gán nhóm. Gán nhóm cho cả trang nhưng biến thể nằm ở bước cuối checkout thì phần lớn người "tham gia" chưa từng thấy nó.
  • Kiểm tra tỉ lệ chia nhóm: chia 50/50 mà dữ liệu ra 47/53 với lượng mẫu lớn thường là dấu hiệu lỗi kỹ thuật (bot, lỗi gửi sự kiện ở một biến thể), không phải ngẫu nhiên.
  • Đừng xem kết quả liên tục rồi dừng sớm khi thấy số đẹp — đây là cách dễ nhất để có kết luận sai. Quyết định lượng mẫu và thời gian chạy trước.
  • Nhấp nháy làm sai kết quả: biến thể nào bị nhấp nháy thì trải nghiệm tệ hơn vì lý do không liên quan đến thay đổi bạn muốn đo.
  • Các thí nghiệm chồng lên nhau trên cùng một trang có thể ảnh hưởng lẫn nhau.

Mình không phải chuyên gia thống kê, và phần lớn frontend engineer cũng vậy. Việc của mình là đảm bảo dữ liệu sạch: gán nhóm ổn định, exposure chính xác, không nhấp nháy. Phần phân tích nên để người có chuyên môn hoặc công cụ thí nghiệm có sẵn xử lý.

Nợ flag và quy trình dọn dẹp#

Mỗi flag là một nhánh if mà code phải mang theo. Hai flag là bốn tổ hợp, mười flag là hơn một nghìn tổ hợp mà về lý thuyết đều có thể xảy ra. Flag không được dọn là nợ kỹ thuật lãi kép.

Quy trình mình áp dụng:

  1. Ngày hết hạn là bắt buộc với release và experiment flag khi tạo.
  2. CI báo lỗi khi flag quá hạn. Một script nhỏ là đủ:
scripts/check-expired-flags.ts
import { flagDefinitions } from "../src/flags/definitions";

const today = new Date().toISOString().slice(0, 10);

const expired = Object.entries(flagDefinitions).filter(
  ([, definition]) => definition.expiresAt !== undefined && definition.expiresAt < today,
);

if (expired.length > 0) {
  for (const [key, definition] of expired) {
    console.error(`Flag "${key}" đã hết hạn từ ${definition.expiresAt} (owner: ${definition.owner})`);
  }
  process.exit(1);
}
  1. Lint rule cho release flag thiếu hạn: có thể viết một rule ESLint tuỳ chỉnh, hoặc đơn giản hơn là một test kiểm tra mọi flag có kind là release hay experiment đều có expiresAt.
  2. Dọn flag là một phần của "xong": khi rollout đạt 100% và ổn định một thời gian, ticket xoá flag được tạo ngay — xoá nhánh cũ, xoá định nghĩa, xoá cấu hình trên dịch vụ flag.

Nhờ định nghĩa tập trung và có kiểu, xoá một flag khỏi FlagValues sẽ khiến TypeScript chỉ ra mọi chỗ còn dùng nó. Đây là phần thưởng lớn nhất của việc đầu tư từ đầu.

Test cả hai nhánh#

Code sau flag có hai đường đi, và cả hai đều có thể chạy trên production. Mình viết test cho cả hai, với một helper render cho phép ghi đè flag:

TSX
import type { ReactElement } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FlagsProvider } from "@/flags/client";
import { flagDefinitions, type FlagValues } from "@/flags/definitions";
import { CheckoutPage } from "./checkout-page";

const defaults = Object.fromEntries(
  Object.entries(flagDefinitions).map(([key, definition]) => [key, definition.defaultValue]),
) as FlagValues;

function renderWithFlags(ui: ReactElement, overrides: Partial<FlagValues> = {}) {
  return render(<FlagsProvider value={{ ...defaults, ...overrides }}>{ui}</FlagsProvider>);
}

describe.each([true, false])("CheckoutPage với newCheckout=%s", (enabled) => {
  it("luôn hiển thị nút thanh toán", () => {
    renderWithFlags(<CheckoutPage />, { newCheckout: enabled });
    expect(screen.getByRole("button", { name: /thanh toán/i })).toBeTruthy();
  });
});

Với e2e, mình chạy các luồng quan trọng nhất ở cả trạng thái flag bật và tắt — đặc biệt trong giai đoạn rollout, khi cả hai nhóm người dùng đều tồn tại thật.

Tóm tắt#

  • Phân biệt bốn loại flag: release, experiment, ops, permission — mỗi loại có vòng đời và chủ sở hữu riêng.
  • Ẩn giao diện bằng flag không phải là bảo mật; quyền truy cập phải được kiểm tra ở server.
  • Định nghĩa flag tập trung, có kiểu, có giá trị mặc định an toàn, chủ sở hữu và ngày hết hạn.
  • Ưu tiên đánh giá flag trên server và bootstrap xuống client để tránh nhấp nháy và layout shift.
  • Rollout dần với phân nhóm ổn định; test kill switch trước khi cần đến nó.
  • Với A/B test: exposure chính xác, kiểm tra tỉ lệ chia nhóm, không dừng sớm.
  • Dọn flag là một phần của "xong": hạn dùng, kiểm tra trong CI, và test cả hai nhánh.