NHTSA vPIC returns dozens of attributes for a single VIN. Your product should not show all of them on day one. Some fields are sparse, some are easy to misread, and some are still experimental in your UI even when the upstream key has existed for years.
Feature flags are the usual answer. The failure mode is also usual: a half-rolled field appears next to Make and Model with the same visual weight, users treat a tentative decode as gospel, and support tickets ask why "Electrification Level" vanished after you flipped the flag off.
This post is a practical pattern for gated decode fields: typed flags, stable core UI, experimental sections that look experimental, and kill switches that do not leave blank holes where trusted data used to be.
Separate core from experiment
Treat the decode response as two layers in the client:
- Core -- always on: make, model, model year, body class, and other fields you have copy and empty-state UX for
- Experimental -- behind a flag: new attributes, richer plant detail, or fields you are still validating against real inventory
type FlagKey = "exp.plantDetail" | "exp.electrification" | "exp.restraintDetail";
type FlagState = Record<FlagKey, boolean>;
type DecodeViewModel = {
vin: string;
core: {
make: string | null;
model: string | null;
modelYear: string | null;
bodyClass: string | null;
};
experimental: {
plantCity: string | null;
plantState: string | null;
electrificationLevel: string | null;
restraintSystem: string | null;
};
};
const DEFAULT_FLAGS: FlagState = {
"exp.plantDetail": false,
"exp.electrification": false,
"exp.restraintDetail": false,
};
export function pickExperimentalFields(
vm: DecodeViewModel,
flags: FlagState = DEFAULT_FLAGS,
): Partial<DecodeViewModel["experimental"]> {
const out: Partial<DecodeViewModel["experimental"]> = {};
if (flags["exp.plantDetail"]) {
out.plantCity = vm.experimental.plantCity;
out.plantState = vm.experimental.plantState;
}
if (flags["exp.electrification"]) {
out.electrificationLevel = vm.experimental.electrificationLevel;
}
if (flags["exp.restraintDetail"]) {
out.restraintSystem = vm.experimental.restraintSystem;
}
return out;
}
The important part: flags gate presentation, not the decode call. You still fetch and store the full mapped DTO. Turning a flag off must not force a second NHTSA round-trip, and turning it on must not invent values that were never in the response.
UX rules that keep trust
Feature flags fail product trust when experimental rows look identical to core rows.
- Put experimental fields in a labeled section ("Additional attributes (preview)") not in the primary card
- Prefer "Not shown yet" or hide the row entirely over an empty dash that looks like a failed decode
- Never promote an experimental field into the hero line (title, H1, social card) until the flag defaults to on for everyone
- If a field is often blank in vPIC, say so in helper text before you ship the flag to 100%
A user who sees Make and Model should not have to guess which other labels are "maybe."
Rollout without layout thrash
When you expand a flag from 5% to 50% to 100%, the page layout should not jump in a way that implies data disappeared.
type Section = "core" | "experimental";
export function visibleSections(flags: FlagState): Section[] {
const anyExp = Object.values(flags).some(Boolean);
return anyExp ? ["core", "experimental"] : ["core"];
}
export function labelForField(
key: keyof DecodeViewModel["experimental"],
flags: FlagState,
): string | null {
const map: Record<typeof key, FlagKey> = {
plantCity: "exp.plantDetail",
plantState: "exp.plantDetail",
electrificationLevel: "exp.electrification",
restraintSystem: "exp.restraintDetail",
};
return flags[map[key]] ? humanLabel(key) : null;
}
function humanLabel(key: string): string {
return key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
}
Reserve space only when the experimental section is active for that cohort. If the section is off, omit it. Do not leave a collapsed "Preview" shell that looks broken.
Kill switch semantics
A kill switch is not a soft reload. Define what "off" means:
| Intent | Behavior |
|---|---|
| Pause a buggy field | Hide that field; keep core |
| Pause a whole preview section | Hide section; keep cached DTO |
| Emergency bad copy | Hide field and suppress analytics for that key |
Do not delete cached experimental values on kill. You will need them for debugging and for a clean re-enable.
Analytics without confusing the funnel
Track flag exposure separately from decode success:
-
decode.ok-- core mapping succeeded -
flag.exposed-- user cohort had flag on -
flag.field_shown-- non-null experimental value rendered
If you only count "field shown," a sparse NHTSA attribute looks like a product bug. If you only count "flag on," you never learn whether the field was useful.
Product checklist
- Flags named by product intent (
exp.plantDetail), not by raw vPIC key - Core UI never depends on a flag defaulting to true
- Experimental section labeled as preview
- Kill switch hides presentation only
- Empty experimental values do not look like decode failures
- Graduation path: flag on at 100% for N days, then move field into core and delete the flag
Takeaway
Feature flags are how you try new VIN attributes without betting the whole decode page. Keep the NHTSA fetch complete, gate only what you render, mark experiments as experiments, and kill presentation -- not data -- when something goes wrong. Users should trust Make and Model even while you are still learning whether Plant City belongs next to them.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)