The flag was green. The payload parsed. Six category tiles rendered on the homepage exactly as the design intended.

One of them pointed at a page that did not exist yet.

Nothing had failed. The console accepted the JSON, the SDK delivered it, the renderer looped over the array and painted the tiles. Every system in the chain did the job it was given. The only thing missing was anything at all that knew an href is supposed to lead somewhere.

I wrote about feature-flag-driven development a while ago: what happens when the existence of flags starts deciding the shape of the architecture instead of the other way around. That post was about flags holding booleans in places booleans did not belong.

This is the floor below it.

This is what happens when they stop holding booleans.


Nobody made a bad decision here

It starts somewhere reasonable, which is why it is hard to see.

An experiment. Three headline variants, a clean traffic split, conversion metrics attached to each one. The platform doing exactly what it was bought for.

Then a validation regex, because a partner keeps changing their account ID format and shipping a deploy for a character class feels absurd. Defensible, barely. The pattern changes for reasons outside our release cycle, which is close to the only honest argument for pulling logic out of code.

Then copy, because marketing wants to change a label without opening a ticket.

Then a form schema, because the renderer already reads JSON and one more payload is not a big deal.

Then every form.

And then one day you open a flag called insurance_category and find insurance_bike, insurance_car, insurance_health, each carrying a localized label, an icon URL, and an href.

That is not a flag. That is a table.

Nobody adopted it. It grew up in the payload field regardless.

Every one of those steps was cheaper than the alternative on the day it was taken. The renderer already existed. The flag was already there. The deploy queue was long. The person making the call was not being careless, they were being efficient, and the cost of being efficient forty times only appears in aggregate.

That is the same shape as the CDN migration that removed our Cache-Control headers. No single decision was wrong. The aggregate was the bug.


Payloads are not the mistake

The JSON payload field is genuinely useful, the same way flags are genuinely useful.

A threshold. A list of enabled markets. A sampling rate. Small, flat, volatile at runtime, wrong in only one way. Those belong there and I would put them there again tomorrow.

The mistake is what the field looks like after it has worked twice.

It looks like a place to put anything. It has no opinion about what goes in it, no schema to violate, no type to get wrong. Every other system we work with pushes back when we hand it something malformed. The compiler complains. The linter complains. The API returns a 422.

This one accepts everything.

That is the convenience. That is also the whole problem.


Deciding and storing are different jobs

The distinction that would have prevented all of this is not complicated.

A flag service is good at deciding things. It is bad at storing them.

Deciding is: who sees what, in what proportion, in which market, with what measured effect. That is targeting, and it is the real engineering achievement of these platforms. Evaluate a rule locally, in microseconds, against a user context, and return a branch.

Storing is: this entity has these attributes, there are forty of them, they must stay consistent, and something has to notice when one of them is malformed.

Copy variants are a decision. A form schema is storage. A product catalog is entirely storage.

Sort each case into one of those two buckets and most of the argument disappears.


What a catalog needs that a payload does not

This is worth belabouring, because “it is only JSON, JSON is fine” is the argument that got us here.

Ask where the answers live.

Where is the schema? Every record should carry the same fields with the same types. The console will accept htef instead of href. It will accept a string where the renderer expects an array. It will accept a payload that is valid JSON and completely meaningless, and it will accept it at two in the morning.

Where is the integrity check? The tile and the page it links to ship through two different systems with no release that contains both. That is how a live entry ends up pointing at a 404 with every dashboard green.

Where is the query? “Which categories are live in this market right now” should be something a person can ask the system. Instead it is someone reading a blob and counting by eye.

Where does the ordering come from? Whatever order the keys happened to be typed in. The homepage layout is now an emergent property of text editing.

Where is the review? This is the one that matters. A change log is not a diff. Nobody is approving a pull request on a flag payload. The most effective quality mechanism the team has, another engineer reading the change before it ships, has been routed around entirely, for the data that renders the product.


The tell is who holds the keys

None of that is how you know it has gone wrong. This is:

Only the engineers know what is in there.

The entire justification for pulling structure out of code is so that people who do not write code can change it without a deploy. That is the trade. You give up types, tests, review, and local reproducibility, and in exchange somebody ships a copy change on a Friday without filing a ticket.

If an engineer is the one editing the JSON, guessing which keys the renderer respects, with no autocomplete and no validation and no way to run it locally, then the full price has been paid and nothing came back.

The job got worse in order to avoid a deploy that takes four minutes.

And in a hybrid app, the web layer was never the slow one. Native ships through a store queue and a review clock. The webview redeploys in the time it takes a pipeline to run. The deploy everyone routed around was the cheap one to begin with.

The deploy does not even go away. The moment anyone wants a field type the renderer does not support, you are shipping code and editing a payload, coordinated across two systems, with no release that contains both.

Nothing was eliminated. A second uncoordinated release channel was added on top of the first.


Put things where they live

The antidote is not to swear off payloads, the same way it was never to swear off flags.

A form schema is code. Put it in the repo, validate it in CI, deploy it. Deploys of static config are cheap, and they come with diffs.

A product catalog belongs wherever the products already are. Insurance categories almost certainly exist in a real system somewhere: the service that prices the policies, a partner integration, a table nobody exposed to the frontend. If so, the payload is a hand-maintained duplicate, and the actual bug is that there is no endpoint for the canonical list.

Duplicated catalogs drift. Drift in a product list is visible to customers. That argument works on people who do not care about architecture.

If the list only exists because somebody typed it into a console, then a static file behind a CDN is already a strict upgrade. Reviewable, diffable, type-checked, live in seconds.

Copy belongs in the i18n layer, which solved plurals and interpolation and translator workflow a long time ago. The first payload with a second locale inside it is the beginning of a worse i18n system with none of that.

Then flag the visibility instead of the data. “Bike is live in these markets, health is at ten percent” is a real flag question and the platform is excellent at answering it.

Store the catalog properly. Gate it with a flag. That split keeps everything the tool is good at.


If it is already built

Nobody rewrites this on a Tuesday. Cheapest first.

Put a parser between the payload and the renderer.

const CategorySchema = z.object({
  key: z.string(),
  label: z.string(),
  iconUrl: z.string().url(),
  href: z.string().startsWith("/"),
});
 
const parsed = z.array(CategorySchema).safeParse(payload);
const categories = parsed.success ? parsed.data : BUNDLED_DEFAULT;
 
if (!parsed.success) captureException(parsed.error);

That turns “a malformed payload silently breaks the page for everyone” into “we log it and render the last version that worked”. It is most of the real risk, for about a day of work, and it needs nobody’s permission.

Write down the field vocabulary. Which types exist, which keys each one respects, what is required. That knowledge currently lives in the renderer source and in the heads of whoever wrote each payload. If the parser exists, the parser is the document.

Move the source of truth into the repo. Files in version control, validated in CI, pushed to the console by a script. Nothing about how the application reads them changes. Diffs and history come back.

That last step is also where someone asks why the flag service is still in the path at all. That is a good question to be forced to answer.


The platform did what it promised. It held a value and handed it back fast.

We were the ones who kept saying yes to ten-minute decisions.

Ten minutes is a very hard thing to argue with. Forty of them raise a family.